@semalt-ai/code 1.8.5 → 1.19.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 (146) hide show
  1. package/.claude/settings.local.json +6 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/CLAUDE.md +1584 -26
  4. package/README.md +147 -3
  5. package/examples/embed.js +74 -0
  6. package/index.js +251 -10
  7. package/lib/agent.js +711 -104
  8. package/lib/api.js +213 -49
  9. package/lib/args.js +74 -2
  10. package/lib/audit.js +23 -1
  11. package/lib/background.js +584 -0
  12. package/lib/checkpoints.js +757 -0
  13. package/lib/commands/auth.js +94 -0
  14. package/lib/commands/chat-session.js +306 -0
  15. package/lib/commands/chat-slash.js +399 -0
  16. package/lib/commands/chat-turn.js +446 -0
  17. package/lib/commands/chat.js +403 -0
  18. package/lib/commands/custom.js +157 -0
  19. package/lib/commands/history-utils.js +66 -0
  20. package/lib/commands/index.js +268 -0
  21. package/lib/commands/mcp.js +113 -0
  22. package/lib/commands/oneshot.js +193 -0
  23. package/lib/commands/registry.js +269 -0
  24. package/lib/commands/tasks.js +89 -0
  25. package/lib/compact.js +87 -0
  26. package/lib/config.js +333 -11
  27. package/lib/constants.js +372 -3
  28. package/lib/deny.js +199 -0
  29. package/lib/doctor.js +160 -0
  30. package/lib/headless.js +167 -0
  31. package/lib/hooks.js +286 -0
  32. package/lib/images.js +264 -0
  33. package/lib/internals.js +49 -0
  34. package/lib/mcp/boundary.js +131 -0
  35. package/lib/mcp/client.js +270 -0
  36. package/lib/mcp/oauth.js +134 -0
  37. package/lib/memory.js +209 -0
  38. package/lib/metrics.js +37 -2
  39. package/lib/payload.js +54 -0
  40. package/lib/permission-rules.js +401 -0
  41. package/lib/permissions.js +100 -10
  42. package/lib/pricing.js +67 -0
  43. package/lib/proc.js +62 -0
  44. package/lib/prompts.js +84 -5
  45. package/lib/sandbox.js +568 -0
  46. package/lib/sdk.js +328 -0
  47. package/lib/secrets.js +211 -0
  48. package/lib/skills.js +223 -0
  49. package/lib/subagents.js +516 -0
  50. package/lib/tool_registry.js +2558 -0
  51. package/lib/tool_specs.js +222 -2
  52. package/lib/tools.js +272 -1020
  53. package/lib/ui/format.js +22 -1
  54. package/lib/ui/input-field.js +16 -7
  55. package/lib/ui/status-bar.js +79 -11
  56. package/lib/ui/theme.js +1 -0
  57. package/lib/ui/web-activity.js +218 -0
  58. package/lib/verify.js +229 -0
  59. package/lib/web-extract.js +213 -0
  60. package/lib/web-summarize.js +68 -0
  61. package/package.json +19 -4
  62. package/scripts/lint.js +57 -0
  63. package/test/agent-loop.test.js +389 -0
  64. package/test/background.test.js +414 -0
  65. package/test/chat.test.js +114 -0
  66. package/test/checkpoints-agent.test.js +181 -0
  67. package/test/checkpoints.test.js +650 -0
  68. package/test/command-registry.test.js +160 -0
  69. package/test/compact.test.js +116 -0
  70. package/test/completion-lazy.test.js +52 -0
  71. package/test/config-merge.test.js +324 -0
  72. package/test/config-quarantine.test.js +128 -0
  73. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  74. package/test/config-write-guard-skip.test.js +46 -0
  75. package/test/config-write-guard.test.js +153 -0
  76. package/test/context-split.test.js +215 -0
  77. package/test/cost-doctor.test.js +142 -0
  78. package/test/custom-commands-chat.test.js +106 -0
  79. package/test/custom-commands.test.js +230 -0
  80. package/test/deny-windows.test.js +120 -0
  81. package/test/deny.test.js +83 -0
  82. package/test/download-allow-anywhere.test.js +66 -0
  83. package/test/download-confine.test.js +153 -0
  84. package/test/executors.test.js +362 -0
  85. package/test/extract-tool-calls.test.js +315 -0
  86. package/test/fetch-url-validation.test.js +219 -0
  87. package/test/fixtures/tool-calls.js +57 -0
  88. package/test/fixtures/web-page.js +91 -0
  89. package/test/git-tools.test.js +384 -0
  90. package/test/grep-glob-serialize.test.js +242 -0
  91. package/test/grep-glob.test.js +268 -0
  92. package/test/harness/README.md +57 -0
  93. package/test/harness/chat-harness.js +142 -0
  94. package/test/harness/memwarn-headless-child.js +65 -0
  95. package/test/harness/mock-llm.js +120 -0
  96. package/test/harness/mock-mcp-server.js +142 -0
  97. package/test/harness/sse-server.js +69 -0
  98. package/test/headless.test.js +203 -0
  99. package/test/history-utils.test.js +88 -0
  100. package/test/hooks-agent.test.js +238 -0
  101. package/test/hooks-verify-sandbox.test.js +232 -0
  102. package/test/hooks.test.js +216 -0
  103. package/test/http-get-user-agent.test.js +142 -0
  104. package/test/images-api.test.js +208 -0
  105. package/test/images.test.js +238 -0
  106. package/test/max-iterations.test.js +216 -0
  107. package/test/mcp-boundary.test.js +57 -0
  108. package/test/mcp-client.test.js +267 -0
  109. package/test/mcp-oauth.test.js +86 -0
  110. package/test/memory-truncation-warning.test.js +222 -0
  111. package/test/memory.test.js +198 -0
  112. package/test/native-dispatch.test.js +356 -0
  113. package/test/output-chokepoint.test.js +188 -0
  114. package/test/path-guards.test.js +134 -0
  115. package/test/payload.test.js +99 -0
  116. package/test/permission-rules-agent.test.js +210 -0
  117. package/test/permission-rules.test.js +297 -0
  118. package/test/permissions.test.js +163 -0
  119. package/test/plan-mode.test.js +167 -0
  120. package/test/read-paginate.test.js +275 -0
  121. package/test/readonly-tools.test.js +177 -0
  122. package/test/result-cap.test.js +233 -0
  123. package/test/sandbox-agent.test.js +147 -0
  124. package/test/sandbox-integration.test.js +216 -0
  125. package/test/sandbox.test.js +408 -0
  126. package/test/sdk.test.js +234 -0
  127. package/test/shell-output-cap.test.js +181 -0
  128. package/test/skills-chat.test.js +110 -0
  129. package/test/skills.test.js +295 -0
  130. package/test/smoke.test.js +68 -0
  131. package/test/status-bar-pause.test.js +164 -0
  132. package/test/stream-parser.test.js +147 -0
  133. package/test/subagents-agent.test.js +178 -0
  134. package/test/subagents.test.js +222 -0
  135. package/test/tool-registry.test.js +85 -0
  136. package/test/trim-budget.test.js +101 -0
  137. package/test/verify-agent.test.js +317 -0
  138. package/test/verify.test.js +141 -0
  139. package/test/web-activity-ordering.test.js +194 -0
  140. package/test/web-activity.test.js +207 -0
  141. package/test/web-data-extraction-guidance.test.js +71 -0
  142. package/test/web-extract.test.js +185 -0
  143. package/test/web-fetch-agent.test.js +291 -0
  144. package/test/web-fetch-mode.test.js +193 -0
  145. package/test/web-search.test.js +380 -0
  146. package/lib/commands.js +0 -1438
@@ -0,0 +1,275 @@
1
+ 'use strict';
2
+
3
+ // Task W.7 — Paginate read_file (start_line/end_line + PARTIAL notice + token net).
4
+ //
5
+ // THE CHANGE these tests pin: read_file used to dump the WHOLE file verbatim into
6
+ // context (the only guard was a hard byte refusal at max_file_size_kb). Now it
7
+ // paginates: under a line cap it reads fully (NO regression for the common
8
+ // small-file case), over it returns the first page + a PARTIAL notice telling the
9
+ // model the range, the total, and the start_line for the next page. start_line/
10
+ // end_line return an explicit slice (still line-capped); a token net bounds
11
+ // pathological long lines.
12
+ //
13
+ // Step 0 finding: edit_file is LINE-NUMBER-based (lines[lineNum-1] = content) and
14
+ // replace_in_file is MATCH-based (regex on a search string). A MIX. Decision:
15
+ // line numbers are OPTIONAL, default OFF (show_line_numbers). Rationale —
16
+ // replace_in_file (the match-based path) needs raw, copyable line text, so
17
+ // default-off keeps snippets verbatim AND avoids the ~1.7x token tax on every
18
+ // read; edit_file (line-based) gets correct line refs on demand via
19
+ // show_line_numbers. Tests assert what the MODEL receives (the audit's method).
20
+
21
+ const { test, before, after } = require('node:test');
22
+ const assert = require('node:assert');
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+ const path = require('path');
26
+
27
+ const { formatReadResult } = require('../lib/agent');
28
+ const {
29
+ DEFAULT_READ_LINE_CAP,
30
+ DEFAULT_READ_MAX_TOKENS,
31
+ } = require('../lib/constants');
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Part A — pure model-facing pagination (formatReadResult)
35
+ // ---------------------------------------------------------------------------
36
+
37
+ function nLines(n, prefix = 'L') {
38
+ return Array.from({ length: n }, (_, i) => `${prefix}${i + 1}`).join('\n');
39
+ }
40
+
41
+ test('small file under the line cap, no range, no numbers → byte-for-byte today (NO regression)', () => {
42
+ const content = 'alpha\nbeta\ngamma\n'; // trailing newline included
43
+ const out = formatReadResult({ content, path: 'f.txt' });
44
+ // Exactly the pre-W.7 shape: `File <path>:\n<content>` with nothing added.
45
+ assert.strictEqual(out, `File f.txt:\n${content}`);
46
+ assert.doesNotMatch(out, /PARTIAL/);
47
+ assert.doesNotMatch(out, /token-capped/);
48
+ });
49
+
50
+ test('split/join round-trips exactly — equivalence holds for content without a trailing newline', () => {
51
+ const content = 'one\ntwo\nthree';
52
+ const out = formatReadResult({ content, path: 'g.txt' });
53
+ assert.strictEqual(out, `File g.txt:\n${content}`);
54
+ });
55
+
56
+ test('large file over the cap, no range → first page (lineCap lines) + a PARTIAL notice', () => {
57
+ const total = DEFAULT_READ_LINE_CAP + 234; // e.g. 2234
58
+ const content = nLines(total);
59
+ const out = formatReadResult({ content, path: 'big.txt' });
60
+
61
+ // First page present: line 1 .. line lineCap.
62
+ assert.match(out, /^File big\.txt:\nL1\n/, 'starts at the top');
63
+ assert.match(out, new RegExp(`(^|\\n)L${DEFAULT_READ_LINE_CAP}(\\n|$|\\s)`), 'last line of the page present');
64
+ // Beyond the page is NOT included.
65
+ assert.doesNotMatch(out, new RegExp(`(^|\\n)L${DEFAULT_READ_LINE_CAP + 1}(\\n|$)`), 'next page not dumped');
66
+ assert.doesNotMatch(out, new RegExp(`(^|\\n)L${total}(\\n|$)`), 'EOF line not dumped');
67
+
68
+ // PARTIAL notice states range, total, and the next start_line.
69
+ assert.match(out, /PARTIAL/);
70
+ assert.match(out, new RegExp(`lines 1[–-]${DEFAULT_READ_LINE_CAP} of ${total}`));
71
+ assert.match(out, new RegExp(`start_line=${DEFAULT_READ_LINE_CAP + 1}`));
72
+ });
73
+
74
+ test('PAIRED: a file exactly AT the cap reads fully with NO PARTIAL notice', () => {
75
+ const content = nLines(DEFAULT_READ_LINE_CAP);
76
+ const out = formatReadResult({ content, path: 'atcap.txt' });
77
+ assert.strictEqual(out, `File atcap.txt:\n${content}`);
78
+ assert.doesNotMatch(out, /PARTIAL/);
79
+ });
80
+
81
+ test('explicit start_line/end_line → exactly that slice', () => {
82
+ const content = nLines(500);
83
+ const out = formatReadResult({ content, path: 'r.txt', startLine: 100, endLine: 105 });
84
+ // Lines 100..105 inclusive, nothing outside.
85
+ for (const n of [100, 101, 102, 103, 104, 105]) {
86
+ assert.match(out, new RegExp(`(^|\\n)L${n}(\\n|$)`), `L${n} present`);
87
+ }
88
+ assert.doesNotMatch(out, /(^|\n)L99(\n|$)/, 'before-range excluded');
89
+ assert.doesNotMatch(out, /(^|\n)L106(\n|$)/, 'after-range excluded');
90
+ // EOF (500) is past the slice → there IS more, so a PARTIAL/continuation hint.
91
+ assert.match(out, /start_line=106/);
92
+ });
93
+
94
+ test('a HUGE explicit range is still line-capped (cannot dump everything)', () => {
95
+ const total = DEFAULT_READ_LINE_CAP * 3;
96
+ const content = nLines(total);
97
+ const out = formatReadResult({ content, path: 'huge.txt', startLine: 1, endLine: total });
98
+ // Only the first lineCap lines come back even though the range asked for all.
99
+ assert.match(out, new RegExp(`(^|\\n)L${DEFAULT_READ_LINE_CAP}(\\n|$|\\s)`));
100
+ assert.doesNotMatch(out, new RegExp(`(^|\\n)L${DEFAULT_READ_LINE_CAP + 1}(\\n|$)`));
101
+ assert.match(out, /PARTIAL/);
102
+ assert.match(out, new RegExp(`start_line=${DEFAULT_READ_LINE_CAP + 1}`));
103
+ });
104
+
105
+ test('start_line offset window is line-capped relative to the start', () => {
106
+ const content = nLines(10000);
107
+ const out = formatReadResult({ content, path: 'w.txt', startLine: 5000 });
108
+ const end = 5000 + DEFAULT_READ_LINE_CAP - 1;
109
+ assert.match(out, new RegExp(`(^|\\n)L5000(\\n|$)`), 'window starts at start_line');
110
+ assert.match(out, new RegExp(`(^|\\n)L${end}(\\n|$|\\s)`), 'window ends one cap later');
111
+ assert.doesNotMatch(out, new RegExp(`(^|\\n)L${end + 1}(\\n|$)`));
112
+ assert.match(out, new RegExp(`start_line=${end + 1}`));
113
+ });
114
+
115
+ test('show_line_numbers default OFF → no number prefixes; ON → correct, aligned numbers', () => {
116
+ const content = 'first\nsecond\nthird';
117
+ const off = formatReadResult({ content, path: 'n.txt' });
118
+ assert.strictEqual(off, `File n.txt:\n${content}`, 'default-off is verbatim (copyable for replace_in_file)');
119
+
120
+ const on = formatReadResult({ content, path: 'n.txt', showLineNumbers: true });
121
+ // Each line carries its 1-based number; the number aligns with edit_file's
122
+ // lines[lineNum-1] addressing (line N => lines[N-1]).
123
+ assert.match(on, /(^|\n)1\tfirst(\n|$)/);
124
+ assert.match(on, /(^|\n)2\tsecond(\n|$)/);
125
+ assert.match(on, /(^|\n)3\tthird(\n|$)/);
126
+ });
127
+
128
+ test('show_line_numbers on a paginated window numbers by ABSOLUTE line', () => {
129
+ const content = nLines(500);
130
+ const out = formatReadResult({ content, path: 'a.txt', startLine: 250, endLine: 252, showLineNumbers: true });
131
+ assert.match(out, /(^|\n)250\tL250(\n|$)/);
132
+ assert.match(out, /(^|\n)251\tL251(\n|$)/);
133
+ assert.match(out, /(^|\n)252\tL252(\n|$)/);
134
+ });
135
+
136
+ test('token net: pathological long lines are token-bounded even within the line cap', () => {
137
+ // 3 lines, each 60k chars → ~45k tokens at char/4, over the 25k default budget.
138
+ const content = ['x'.repeat(200000), 'y'.repeat(200000), 'z'.repeat(200000)].join('\n');
139
+ const out = formatReadResult({ content, path: 'long.txt' });
140
+ assert.match(out, /token-capped/, 'token safety net fired');
141
+ // Bounded to ~the token budget in chars (25k tokens * 4 ≈ 100k), far below
142
+ // the 600k raw chars.
143
+ assert.ok(out.length < content.length / 4, `token-bounded: ${out.length} << ${content.length}`);
144
+ assert.ok(out.length < DEFAULT_READ_MAX_TOKENS * 4 + 2000, 'bounded near the token budget');
145
+ });
146
+
147
+ test('PAIRED: a normal full-size page is NOT token-clipped (pagination shows the whole page)', () => {
148
+ const content = nLines(DEFAULT_READ_LINE_CAP); // short lines, well under the token net
149
+ const out = formatReadResult({ content, path: 'normal.txt' });
150
+ assert.doesNotMatch(out, /token-capped/);
151
+ assert.strictEqual(out, `File normal.txt:\n${content}`);
152
+ });
153
+
154
+ test('config-supplied lineCap/maxTokens override the defaults', () => {
155
+ const content = nLines(40);
156
+ const out = formatReadResult({ content, path: 'c.txt', lineCap: 10 });
157
+ assert.match(out, /(^|\n)L10(\n|$|\s)/);
158
+ assert.doesNotMatch(out, /(^|\n)L11(\n|$)/);
159
+ assert.match(out, /lines 1[–-]10 of 40/);
160
+ assert.match(out, /start_line=11/);
161
+ });
162
+
163
+ test('non-string / empty content handled without throwing', () => {
164
+ assert.strictEqual(formatReadResult({ content: '', path: 'e.txt' }), 'File e.txt:\n');
165
+ assert.strictEqual(formatReadResult({ content: null, path: 'e.txt' }), 'File e.txt:\n');
166
+ });
167
+
168
+ test('start_line past EOF degrades to a clear note, not a crash', () => {
169
+ const content = nLines(5);
170
+ const out = formatReadResult({ content, path: 'p.txt', startLine: 99 });
171
+ assert.match(out, /past end of file|past EOF|beyond/i);
172
+ assert.doesNotMatch(out, /PARTIAL/);
173
+ });
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Part B — both rails: XML + native produce parity tuples with start_line/end_line
177
+ // ---------------------------------------------------------------------------
178
+
179
+ const { extractToolCalls, mapInvokeToCall } = require('../lib/tools');
180
+
181
+ test('XML attr rail: start_line/end_line/show_line_numbers parse onto the tuple', () => {
182
+ const calls = extractToolCalls('<read_file path="a.txt" start_line="10" end_line="20" show_line_numbers="true"/>');
183
+ assert.deepStrictEqual(calls, [['read', 'a.txt', 10, 20, true]]);
184
+ });
185
+
186
+ test('XML inline rail: no range → nulls + false, parity preserved', () => {
187
+ const calls = extractToolCalls('<read_file>a.txt</read_file>');
188
+ assert.deepStrictEqual(calls, [['read', 'a.txt', null, null, false]]);
189
+ });
190
+
191
+ test('native rail: fromParams maps start_line/end_line/show_line_numbers identically', () => {
192
+ assert.deepStrictEqual(
193
+ mapInvokeToCall('read_file', { path: 'a.txt', start_line: 10, end_line: 20, show_line_numbers: true }),
194
+ ['read', 'a.txt', 10, 20, true],
195
+ );
196
+ // Absent → nulls + false, byte-identical to the XML inline rail.
197
+ assert.deepStrictEqual(
198
+ mapInvokeToCall('read_file', { path: 'a.txt' }),
199
+ ['read', 'a.txt', null, null, false],
200
+ );
201
+ });
202
+
203
+ test('RAIL PARITY: XML and native produce the SAME tuple for the same intent', () => {
204
+ const xml = extractToolCalls('<read_file path="b.txt" start_line="3" end_line="7"/>')[0];
205
+ const native = mapInvokeToCall('read_file', { path: 'b.txt', start_line: 3, end_line: 7 });
206
+ assert.deepStrictEqual(xml, native);
207
+ });
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Part C — executor + the read→edit loop (Step 0 alignment)
211
+ // ---------------------------------------------------------------------------
212
+
213
+ const ui = require('../lib/ui');
214
+ const { createToolExecutor } = require('../lib/tools');
215
+ const { createPermissionManager } = require('../lib/permissions');
216
+
217
+ let TMP, CWD, exec, ef;
218
+ let prevKey;
219
+ before(() => {
220
+ prevKey = process.env.SEMALT_API_KEY;
221
+ process.env.SEMALT_API_KEY = 'test-key';
222
+ TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'w7-read-'));
223
+ CWD = TMP;
224
+ const ui2 = ui;
225
+ const pm = createPermissionManager(ui2, { skipPermissions: true });
226
+ pm.setUICallbacks({ onAddMessage: () => {}, onShowModal: () => {}, onCloseModal: () => {}, onCaptureNavigation: () => () => {} });
227
+ exec = createToolExecutor(pm, ui2, () => ({ max_file_size_kb: 512, command_timeout_ms: 30000 }));
228
+ ef = (action, ...args) => exec.agentExecFile(action, ...args);
229
+ });
230
+ after(() => {
231
+ if (prevKey === undefined) delete process.env.SEMALT_API_KEY;
232
+ else process.env.SEMALT_API_KEY = prevKey;
233
+ try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* ignore */ }
234
+ });
235
+
236
+ test('executor read returns the FULL content (pagination is a context-boundary concern, not a read truncation)', async () => {
237
+ const big = path.join(TMP, 'big.txt');
238
+ const content = nLines(DEFAULT_READ_LINE_CAP + 50);
239
+ fs.writeFileSync(big, content);
240
+ const r = await ef('read', big);
241
+ assert.strictEqual(r.error, undefined);
242
+ assert.strictEqual(r.content, content, 'executor does not truncate — formatReadResult paginates for context');
243
+ });
244
+
245
+ test('byte backstop still hard-refuses a file over an explicitly small max_file_size_kb', async () => {
246
+ const pm = createPermissionManager(ui, { skipPermissions: true });
247
+ const tinyExec = createToolExecutor(pm, ui, () => ({ max_file_size_kb: 1 }));
248
+ const big = path.join(TMP, 'over.txt');
249
+ fs.writeFileSync(big, 'x'.repeat(4000));
250
+ const r = await tinyExec.agentExecFile('read', big);
251
+ assert.ok(r.error && /too large/i.test(r.error), 'byte backstop remains as a hard ceiling when configured');
252
+ });
253
+
254
+ test('read→edit alignment: line-based edit_file targets the line show_line_numbers labels', async () => {
255
+ const file = path.join(TMP, 'edit.txt');
256
+ fs.writeFileSync(file, 'aaa\nbbb\nccc\nddd');
257
+ // Read with numbers; the label N must equal edit_file's lines[N-1].
258
+ const r = await ef('read', file);
259
+ const numbered = formatReadResult({ content: r.content, path: file, showLineNumbers: true });
260
+ assert.match(numbered, /(^|\n)3\tccc(\n|$)/, 'line 3 is labeled ccc');
261
+ // edit_file line 3 → replaces lines[2] (ccc).
262
+ await ef('edit_file', file, 3, 'CCC');
263
+ assert.strictEqual(fs.readFileSync(file, 'utf8'), 'aaa\nbbb\nCCC\nddd');
264
+ });
265
+
266
+ test('read→edit alignment: match-based replace_in_file uses a snippet copied verbatim from default read', async () => {
267
+ const file = path.join(TMP, 'replace.txt');
268
+ fs.writeFileSync(file, 'const x = 1;\nconst y = 2;\n');
269
+ const r = await ef('read', file);
270
+ const shown = formatReadResult({ content: r.content, path: file }); // default: NO line numbers
271
+ // The default output carries the raw line verbatim → copyable as a search string.
272
+ assert.ok(shown.includes('const y = 2;'), 'snippet is present verbatim (no number prefix to corrupt the match)');
273
+ await ef('replace_in_file', file, 'const y = 2;', 'const y = 42;', '');
274
+ assert.strictEqual(fs.readFileSync(file, 'utf8'), 'const x = 1;\nconst y = 42;\n');
275
+ });
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+
3
+ // Pre-Task 5.0c — complete `--readonly` enforcement.
4
+ // The re-audit found --readonly did NOT block edit_file, replace_in_file,
5
+ // make_dir, remove_dir, or upload — so a "read-only" session could still
6
+ // mutate files via those tools. These tests pin the completed contract:
7
+ // - the five newly-added file-mutating tools are each refused under --readonly
8
+ // (no mutation reaches disk),
9
+ // - the previously-blocked set still refused (no regression),
10
+ // - shell is NOT blocked by --readonly (the documented decision: --readonly
11
+ // governs FILE TOOLS only; shell side effects are confined by the sandbox /
12
+ // deny-list, not --readonly),
13
+ // - a read-only tool still works under --readonly.
14
+ //
15
+ // Home-based paths are redirected into a temp dir BEFORE any lib module loads,
16
+ // matching test/download-confine.test.js.
17
+
18
+ const os = require('node:os');
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+
22
+ const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-ro-home-'));
23
+ const PREV_HOME = process.env.HOME;
24
+ const PREV_USERPROFILE = process.env.USERPROFILE;
25
+ process.env.HOME = TMP_HOME;
26
+ process.env.USERPROFILE = TMP_HOME;
27
+
28
+ const { test, before, after } = require('node:test');
29
+ const assert = require('node:assert');
30
+
31
+ const ui = require('../lib/ui');
32
+ const { createPermissionManager } = require('../lib/permissions');
33
+ const { createToolExecutor } = require('../lib/tools');
34
+
35
+ let CWD;
36
+ let PREV_CWD;
37
+
38
+ function mkExec({ config = {}, pmOpts = {} } = {}) {
39
+ const pm = createPermissionManager(ui, pmOpts);
40
+ return createToolExecutor(pm, ui, () => ({
41
+ max_file_size_kb: 512,
42
+ command_timeout_ms: 30000,
43
+ max_output_lines: 50,
44
+ ...config,
45
+ }));
46
+ }
47
+
48
+ before(() => {
49
+ PREV_CWD = process.cwd();
50
+ CWD = fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-ro-cwd-'));
51
+ process.chdir(CWD);
52
+ });
53
+
54
+ after(() => {
55
+ process.chdir(PREV_CWD);
56
+ if (PREV_HOME === undefined) delete process.env.HOME; else process.env.HOME = PREV_HOME;
57
+ if (PREV_USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = PREV_USERPROFILE;
58
+ });
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // readonlyBlock — the full file-mutating set is blocked, reads are not
62
+ // ---------------------------------------------------------------------------
63
+
64
+ test('readonlyBlock covers every file-mutating tool when --readonly is set', () => {
65
+ const ro = createPermissionManager(ui, { readonly: true });
66
+ // newly added (Pre-Task 5.0c)
67
+ for (const tag of ['edit_file', 'replace_in_file', 'make_dir', 'remove_dir', 'upload']) {
68
+ assert.deepStrictEqual(ro.readonlyBlock(tag), { error: 'blocked by --readonly' }, `${tag} must be blocked`);
69
+ }
70
+ // previously blocked — no regression
71
+ for (const tag of ['write_file', 'append_file', 'delete_file', 'move_file', 'copy_file', 'download']) {
72
+ assert.deepStrictEqual(ro.readonlyBlock(tag), { error: 'blocked by --readonly' }, `${tag} must stay blocked`);
73
+ }
74
+ // read-class operations are allowed even in readonly mode
75
+ for (const tag of ['read_file', 'list_dir', 'grep', 'glob', 'search_in_file', 'file_stat']) {
76
+ assert.strictEqual(ro.readonlyBlock(tag), null, `${tag} must be allowed`);
77
+ }
78
+ });
79
+
80
+ test('readonlyBlock does NOT block shell/exec — shell is governed by the sandbox, not --readonly', () => {
81
+ const ro = createPermissionManager(ui, { readonly: true });
82
+ assert.strictEqual(ro.readonlyBlock('exec'), null, 'exec is not a file tool; --readonly does not block it');
83
+ assert.strictEqual(ro.readonlyBlock('shell'), null, 'shell is not a file tool; --readonly does not block it');
84
+ });
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // executor-level — each newly-added tool is refused and leaves no mutation
88
+ // ---------------------------------------------------------------------------
89
+
90
+ test('edit_file is blocked under --readonly (no mutation)', async () => {
91
+ const exec = mkExec({ pmOpts: { readonly: true } });
92
+ const f = path.join(CWD, 'edit.txt');
93
+ fs.writeFileSync(f, 'original\n');
94
+ const r = await exec.agentExecFile('edit_file', f, 1, 'changed');
95
+ assert.ok(r.error, 'should be blocked');
96
+ assert.match(r.error, /readonly/i);
97
+ assert.strictEqual(fs.readFileSync(f, 'utf8'), 'original\n', 'file must be untouched');
98
+ });
99
+
100
+ test('replace_in_file is blocked under --readonly (no mutation)', async () => {
101
+ const exec = mkExec({ pmOpts: { readonly: true } });
102
+ const f = path.join(CWD, 'replace.txt');
103
+ fs.writeFileSync(f, 'foo bar\n');
104
+ const r = await exec.agentExecFile('replace_in_file', f, 'foo', 'baz', '');
105
+ assert.ok(r.error, 'should be blocked');
106
+ assert.match(r.error, /readonly/i);
107
+ assert.strictEqual(fs.readFileSync(f, 'utf8'), 'foo bar\n', 'file must be untouched');
108
+ });
109
+
110
+ test('make_dir is blocked under --readonly (no mutation)', async () => {
111
+ const exec = mkExec({ pmOpts: { readonly: true } });
112
+ const d = path.join(CWD, 'newdir');
113
+ const r = await exec.agentExecFile('make_dir', d);
114
+ assert.ok(r.error, 'should be blocked');
115
+ assert.match(r.error, /readonly/i);
116
+ assert.strictEqual(fs.existsSync(d), false, 'directory must not be created');
117
+ });
118
+
119
+ test('remove_dir is blocked under --readonly (no mutation)', async () => {
120
+ const exec = mkExec({ pmOpts: { readonly: true } });
121
+ const d = path.join(CWD, 'keepdir');
122
+ fs.mkdirSync(d, { recursive: true });
123
+ const r = await exec.agentExecFile('remove_dir', d);
124
+ assert.ok(r.error, 'should be blocked');
125
+ assert.match(r.error, /readonly/i);
126
+ assert.strictEqual(fs.existsSync(d), true, 'directory must still exist');
127
+ });
128
+
129
+ test('upload is blocked under --readonly (no mutation)', async () => {
130
+ const exec = mkExec({ pmOpts: { readonly: true } });
131
+ const f = path.join(CWD, 'uploaded.bin');
132
+ const r = await exec.agentExecFile('upload', f, Buffer.from('hi').toString('base64'));
133
+ assert.ok(r.error, 'should be blocked');
134
+ assert.match(r.error, /readonly/i);
135
+ assert.strictEqual(fs.existsSync(f), false, 'file must not be written');
136
+ });
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // no regression — the previously-blocked set still refused at the executor
140
+ // ---------------------------------------------------------------------------
141
+
142
+ test('write_file is still blocked under --readonly (no regression)', async () => {
143
+ const exec = mkExec({ pmOpts: { readonly: true } });
144
+ const f = path.join(CWD, 'write.txt');
145
+ const r = await exec.agentExecFile('write', f, 'data');
146
+ assert.ok(r.error, 'should be blocked');
147
+ assert.match(r.error, /readonly/i);
148
+ assert.strictEqual(fs.existsSync(f), false, 'file must not be written');
149
+ });
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // describePermission short-circuits the newly-added tools under --readonly
153
+ // (parity with delete/move/copy/download) so no approval prompt precedes the
154
+ // deterministic block.
155
+ // ---------------------------------------------------------------------------
156
+
157
+ test('describePermission returns null for the newly-added tools under --readonly', async () => {
158
+ const exec = mkExec({ pmOpts: { readonly: true } });
159
+ assert.strictEqual(await exec.describePermission(['edit_file', '/x', 1, 'y']), null);
160
+ assert.strictEqual(await exec.describePermission(['replace_in_file', '/x', 'a', 'b', '']), null);
161
+ assert.strictEqual(await exec.describePermission(['make_dir', '/x']), null);
162
+ assert.strictEqual(await exec.describePermission(['remove_dir', '/x']), null);
163
+ assert.strictEqual(await exec.describePermission(['upload', '/x', 'aGk=']), null);
164
+ });
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // a read-only tool still works under --readonly
168
+ // ---------------------------------------------------------------------------
169
+
170
+ test('read_file still works under --readonly', async () => {
171
+ const exec = mkExec({ pmOpts: { readonly: true } });
172
+ const f = path.join(CWD, 'readme.txt');
173
+ fs.writeFileSync(f, 'hello\n');
174
+ const r = await exec.agentExecFile('read', f);
175
+ assert.ok(!r.error, 'read must not be blocked');
176
+ assert.match(r.content || '', /hello/);
177
+ });