start-command 0.40.0 → 0.42.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.
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Structured `read`, `edit` and `write` tools of the experimental
3
+ * `--use-formal-ai` mode (issue #258).
4
+ *
5
+ * The `bash` tool runs only programs without shell syntax, and its write
6
+ * programs (cp, mkdir, mv, rm, rmdir, touch) cannot change the text of a
7
+ * file. These three tools are the guarded replacement primitive instead of an
8
+ * interpreter: start itself reads and writes the one named workspace file,
9
+ * byte for byte, so comments, whitespace and unrelated content survive.
10
+ *
11
+ * - read: the exact UTF-8 text of a regular file, bounded by
12
+ * START_FORMAL_AI_MAX_FILE_BYTES; it observes the file for later changes.
13
+ * - edit: replaces one literal occurrence of old_string (or every one with
14
+ * replace_all) in a file read before, atomically, then reads it back.
15
+ * - write: replaces an observed file, or creates one in an observed
16
+ * directory, atomically, then reads it back.
17
+ *
18
+ * Paths follow the bash tool's workspace policy (no absolute, escaping or
19
+ * symlinked-out paths), edit and write need START_FORMAL_AI_EFFECTS=
20
+ * workspace-write and an earlier observation of the target, and a file that
21
+ * changed since start read it is not overwritten.
22
+ */
23
+
24
+ const crypto = require('crypto');
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const policy = require('./formal-ai-policy');
28
+
29
+ const { TextDecoder } = globalThis;
30
+
31
+ const PATH = {
32
+ type: 'string',
33
+ description: 'File path inside the working directory',
34
+ };
35
+ const FILE_TOOLS = Object.freeze({
36
+ read: Object.freeze({
37
+ type: 'function',
38
+ function: {
39
+ name: 'read',
40
+ description: 'Read the exact text of one file in the working directory.',
41
+ parameters: {
42
+ type: 'object',
43
+ properties: { path: PATH },
44
+ required: ['path'],
45
+ },
46
+ },
47
+ }),
48
+ edit: Object.freeze({
49
+ type: 'function',
50
+ function: {
51
+ name: 'edit',
52
+ description:
53
+ 'Replace the literal text old_string with new_string in one file read before; old_string must occur exactly once unless replace_all is true.',
54
+ parameters: {
55
+ type: 'object',
56
+ properties: {
57
+ path: PATH,
58
+ old_string: { type: 'string' },
59
+ new_string: { type: 'string' },
60
+ replace_all: { type: 'boolean' },
61
+ },
62
+ required: ['path', 'old_string', 'new_string'],
63
+ },
64
+ },
65
+ }),
66
+ write: Object.freeze({
67
+ type: 'function',
68
+ function: {
69
+ name: 'write',
70
+ description:
71
+ 'Write the whole text of one file: a file read before, or a new file in a directory listed before.',
72
+ parameters: {
73
+ type: 'object',
74
+ properties: { path: PATH, content: { type: 'string' } },
75
+ required: ['path', 'content'],
76
+ },
77
+ },
78
+ }),
79
+ });
80
+
81
+ /** Argument names Formal AI and other agents use for the same field. */
82
+ const ALIASES = Object.freeze({
83
+ path: ['path', 'filePath', 'file_path'],
84
+ oldString: ['old_string', 'oldString', 'old_str', 'old'],
85
+ newString: ['new_string', 'newString', 'new_str', 'new'],
86
+ replaceAll: ['replace_all', 'replaceAll'],
87
+ content: ['content'],
88
+ });
89
+ const FIELDS = Object.freeze({
90
+ read: ['path'],
91
+ edit: ['path', 'oldString', 'newString', 'replaceAll'],
92
+ write: ['path', 'content'],
93
+ });
94
+ const REQUIRED = Object.freeze({
95
+ read: ['path'],
96
+ edit: ['path', 'oldString', 'newString'],
97
+ write: ['path', 'content'],
98
+ });
99
+
100
+ /**
101
+ * Normalize the JSON arguments of a file tool call.
102
+ * @param {string} tool - read, edit or write
103
+ * @param {object} args - Parsed arguments
104
+ * @returns {{args?: object, problem?: string}}
105
+ */
106
+ function normalizeFileArgs(tool, args) {
107
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
108
+ return { problem: `${tool} arguments are not a JSON object` };
109
+ }
110
+ const result = {};
111
+ for (const field of FIELDS[tool]) {
112
+ const type = field === 'replaceAll' ? 'boolean' : 'string';
113
+ const values = ALIASES[field]
114
+ .filter((key) => Object.prototype.hasOwnProperty.call(args, key))
115
+ .map((key) => args[key]);
116
+ if (values.some((value) => typeof value !== type)) {
117
+ return {
118
+ problem: `${tool} argument ${ALIASES[field][0]} must be a ${type}`,
119
+ };
120
+ }
121
+ if (values.some((value) => value !== values[0])) {
122
+ return {
123
+ problem: `${tool} received conflicting ${ALIASES[field][0]} values`,
124
+ };
125
+ }
126
+ if (values.length) {
127
+ result[field] = values[0];
128
+ }
129
+ }
130
+ const missing = REQUIRED[tool].filter((field) => !(field in result));
131
+ if (missing.length) {
132
+ return {
133
+ problem: `${tool} needs ${missing.map((f) => ALIASES[f][0]).join(', ')}`,
134
+ };
135
+ }
136
+ if (!result.path.trim() || result.path.includes('\0')) {
137
+ return { problem: `${tool} needs a non-empty path` };
138
+ }
139
+ return { args: { replaceAll: false, ...result } };
140
+ }
141
+
142
+ /** One-line rendering of a file call for notes and evidence. */
143
+ function describeFileCall(tool, args) {
144
+ return `${tool} ${args.path}`;
145
+ }
146
+
147
+ /**
148
+ * Decide whether a file tool call may run (the effect policy of #255).
149
+ * @param {string} tool - read, edit or write
150
+ * @param {object} args - normalizeFileArgs() result
151
+ * @param {object} context - {cwd, effects, observed, observedDirs}
152
+ * @returns {{allowed: boolean, reason?: string, kind?: string, target?: string}}
153
+ */
154
+ function evaluateFileCall(tool, args, context) {
155
+ const refuse = (reason) => ({ allowed: false, reason });
156
+ const kind = tool === 'read' ? 'read' : 'write';
157
+ if (kind === 'write' && context.effects !== 'workspace-write') {
158
+ return refuse(
159
+ `"${tool}" changes files; the effect mode is read-only (set START_FORMAL_AI_EFFECTS=workspace-write to allow workspace changes)`
160
+ );
161
+ }
162
+ if (args.path.startsWith('~')) {
163
+ return refuse(`"${args.path}" is outside the working directory`);
164
+ }
165
+ const target = policy.workspacePath(context.cwd, args.path);
166
+ if (target === null) {
167
+ return refuse(`"${args.path}" is outside the working directory`);
168
+ }
169
+ if (target === '.') {
170
+ return refuse(`"${args.path}" is the working directory, not a file`);
171
+ }
172
+ if (kind === 'write') {
173
+ const exists = fs.existsSync(
174
+ path.join(fs.realpathSync(context.cwd), target)
175
+ );
176
+ const observed =
177
+ context.observed.has(target) ||
178
+ (tool === 'write' &&
179
+ !exists &&
180
+ context.observedDirs.has(path.dirname(target)));
181
+ if (!observed) {
182
+ return refuse(
183
+ `target "${target}" was not ${tool === 'edit' ? 'read' : 'observed'} before the change`
184
+ );
185
+ }
186
+ }
187
+ return { allowed: true, kind, target };
188
+ }
189
+
190
+ const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex');
191
+ // ignoreBOM keeps a byte order mark in the text, so an edit writes it back.
192
+ const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
193
+
194
+ class FileToolError extends Error {}
195
+
196
+ /** Text of a regular workspace file, refusing what read could not return exactly. */
197
+ function readText(file, maxFileBytes) {
198
+ let stat;
199
+ try {
200
+ stat = fs.statSync(file);
201
+ } catch (error) {
202
+ throw new FileToolError(
203
+ error.code === 'ENOENT'
204
+ ? 'no such file'
205
+ : `cannot read the file (${error.code || error.message})`
206
+ );
207
+ }
208
+ if (stat.isDirectory()) {
209
+ throw new FileToolError('is a directory, not a file');
210
+ }
211
+ if (!stat.isFile()) {
212
+ throw new FileToolError('is not a regular file');
213
+ }
214
+ if (stat.size > maxFileBytes) {
215
+ throw new FileToolError(
216
+ `has ${stat.size} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${maxFileBytes}`
217
+ );
218
+ }
219
+ const bytes = fs.readFileSync(file);
220
+ if (bytes.length > maxFileBytes) {
221
+ throw new FileToolError(
222
+ `has ${bytes.length} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${maxFileBytes}`
223
+ );
224
+ }
225
+ let text;
226
+ try {
227
+ text = decoder.decode(bytes);
228
+ } catch {
229
+ text = null;
230
+ }
231
+ if (text === null || text.includes('\0')) {
232
+ throw new FileToolError('is not UTF-8 text');
233
+ }
234
+ return { text, bytes, mode: stat.mode };
235
+ }
236
+
237
+ /** Replace `file` by `content` through a temporary sibling and a rename. */
238
+ function writeAtomically(file, content, mode) {
239
+ const temporary = path.join(
240
+ path.dirname(file),
241
+ `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.start-tmp`
242
+ );
243
+ try {
244
+ fs.writeFileSync(temporary, content, { flag: 'wx', mode: mode ?? 0o666 });
245
+ if (mode !== undefined) {
246
+ fs.chmodSync(temporary, mode & 0o7777);
247
+ }
248
+ const handle = fs.openSync(temporary, 'r+');
249
+ try {
250
+ fs.fsyncSync(handle);
251
+ } finally {
252
+ fs.closeSync(handle);
253
+ }
254
+ fs.renameSync(temporary, file);
255
+ } finally {
256
+ fs.rmSync(temporary, { force: true });
257
+ }
258
+ }
259
+
260
+ function occurrences(text, needle) {
261
+ const found = [];
262
+ for (
263
+ let at = text.indexOf(needle);
264
+ at >= 0;
265
+ at = text.indexOf(needle, at + needle.length)
266
+ ) {
267
+ found.push(at);
268
+ }
269
+ return found;
270
+ }
271
+
272
+ /**
273
+ * Run an approved file tool call.
274
+ * @param {string} tool - read, edit or write
275
+ * @param {object} args - normalizeFileArgs() result
276
+ * @param {object} decision - evaluateFileCall() result
277
+ * @param {object} context - Loop context; `contents` maps paths to the sha256 start last saw
278
+ * @param {object} bounds - {maxFileBytes}
279
+ * @returns {object} Receipt {exitCode, result, error?, bytes?, sha256?, replacements?, verification?}
280
+ */
281
+ function runFileCall(tool, args, decision, context, bounds) {
282
+ const file = path.join(fs.realpathSync(context.cwd), decision.target);
283
+ const { target } = decision;
284
+ const fail = (message) => ({
285
+ exitCode: 1,
286
+ error: `${target}: ${message}`,
287
+ result: JSON.stringify({
288
+ ok: false,
289
+ is_error: true,
290
+ error: `${target}: ${message}`,
291
+ }),
292
+ });
293
+ try {
294
+ if (tool === 'read') {
295
+ const { text, bytes } = readText(file, bounds.maxFileBytes);
296
+ context.observed.add(target);
297
+ context.contents.set(target, sha256(bytes));
298
+ return {
299
+ exitCode: 0,
300
+ result: text,
301
+ text,
302
+ bytes: bytes.length,
303
+ sha256: sha256(bytes),
304
+ };
305
+ }
306
+ let current = null;
307
+ if (tool === 'edit' || fs.existsSync(file)) {
308
+ current = readText(file, bounds.maxFileBytes);
309
+ const seen = context.contents.get(target);
310
+ if (seen !== undefined && seen !== sha256(current.bytes)) {
311
+ return fail(
312
+ 'changed since start read it; read it again before changing it'
313
+ );
314
+ }
315
+ } else if (
316
+ !fs.statSync(path.dirname(file), { throwIfNoEntry: false })?.isDirectory()
317
+ ) {
318
+ return fail('its directory does not exist');
319
+ }
320
+ let updated;
321
+ let replacements = null;
322
+ if (tool === 'edit') {
323
+ if (args.oldString === '') {
324
+ return fail('old_string is empty');
325
+ }
326
+ if (args.oldString === args.newString) {
327
+ return fail('old_string and new_string are the same');
328
+ }
329
+ const found = occurrences(current.text, args.oldString);
330
+ if (!found.length) {
331
+ return fail('old_string was not found; nothing changed');
332
+ }
333
+ if (found.length > 1 && !args.replaceAll) {
334
+ return fail(
335
+ `old_string occurs ${found.length} times; give more surrounding text or set replace_all; nothing changed`
336
+ );
337
+ }
338
+ replacements = args.replaceAll ? found.length : 1;
339
+ updated = args.replaceAll
340
+ ? current.text.split(args.oldString).join(args.newString)
341
+ : `${current.text.slice(0, found[0])}${args.newString}${current.text.slice(found[0] + args.oldString.length)}`;
342
+ } else {
343
+ updated = args.content;
344
+ }
345
+ const expected = Buffer.from(updated, 'utf8');
346
+ if (expected.length > bounds.maxFileBytes) {
347
+ return fail(
348
+ `the new text has ${expected.length} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${bounds.maxFileBytes}`
349
+ );
350
+ }
351
+ writeAtomically(file, expected, current ? current.mode : undefined);
352
+ const readback = fs.readFileSync(file);
353
+ const verified = readback.equals(expected);
354
+ context.observed.add(target);
355
+ context.contents.set(target, sha256(readback));
356
+ return {
357
+ exitCode: 0,
358
+ result: '',
359
+ bytes: expected.length,
360
+ sha256: sha256(readback),
361
+ previousSha256: current ? sha256(current.bytes) : null,
362
+ replacements,
363
+ verification: {
364
+ verified,
365
+ detail: verified
366
+ ? `readback confirmed ${tool} ${target} (${expected.length} bytes)`
367
+ : `readback of ${target} differs from the written text`,
368
+ },
369
+ };
370
+ } catch (error) {
371
+ if (error instanceof FileToolError) {
372
+ return fail(error.message);
373
+ }
374
+ return fail(`failed (${error.code || error.message})`);
375
+ }
376
+ }
377
+
378
+ module.exports = {
379
+ FILE_TOOLS,
380
+ describeFileCall,
381
+ evaluateFileCall,
382
+ normalizeFileArgs,
383
+ runFileCall,
384
+ };