start-command 0.39.2 → 0.41.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 (38) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/eslint.config.mjs +32 -0
  3. package/package.json +9 -10
  4. package/src/bin/cli.js +18 -6
  5. package/src/lib/args-parser.js +33 -0
  6. package/src/lib/command-stream.js +19 -5
  7. package/src/lib/execution-chain.js +2 -2
  8. package/src/lib/execution-store.js +62 -44
  9. package/src/lib/formal-ai-cli.js +237 -0
  10. package/src/lib/formal-ai-client.js +166 -0
  11. package/src/lib/formal-ai-policy.js +501 -0
  12. package/src/lib/formal-ai-runtime.js +304 -0
  13. package/src/lib/formal-ai-tool.js +182 -0
  14. package/src/lib/formal-ai.js +329 -0
  15. package/src/lib/log-sanitizer.js +16 -137
  16. package/src/lib/usage.js +3 -1
  17. package/test/ci-workflow-invariants.js +18 -0
  18. package/test/command-stream-log.js +85 -0
  19. package/test/dependency-freshness.mjs +552 -0
  20. package/test/execution-store-clink.js +152 -0
  21. package/test/execution-store.js +49 -5
  22. package/test/fixtures/executions-lino-objects-codec-0.4.0.lino +1 -0
  23. package/test/fixtures/executions-lino-objects-codec-0.9.3.lino +26 -0
  24. package/test/fixtures/formal-ai/fake-server.cjs +17 -0
  25. package/test/fixtures/formal-ai/helpers.cjs +189 -0
  26. package/test/fixtures/formal-ai/receipts.json +776 -0
  27. package/test/fixtures/formal-ai/replay.cjs +144 -0
  28. package/test/javascript-first-ci.js +29 -0
  29. package/test/log-sanitizer.js +116 -47
  30. package/test/regression-187.js +4 -0
  31. package/test/regression-224.js +4 -2
  32. package/test/regression-231.js +20 -0
  33. package/test/regression-255-cli.js +294 -0
  34. package/test/regression-255-e2e.js +90 -0
  35. package/test/regression-255-loop.js +475 -0
  36. package/test/regression-255-policy.js +436 -0
  37. package/test/release-git.mjs +2 -1
  38. package/test/shared-golden.js +3 -0
@@ -0,0 +1,501 @@
1
+ /**
2
+ * Bounds and effect policy of the experimental `--use-formal-ai` mode
3
+ * (issue #255).
4
+ *
5
+ * Formal AI owns the interpretation of a natural-language request and picks
6
+ * the tool calls; start owns the permission to run them. Every tool call is a
7
+ * POSIX command line that start tokenizes itself and runs as an argv vector,
8
+ * never through a shell, so a call that needs shell syntax (pipes, redirects,
9
+ * substitutions, globs, variables) is refused instead of being interpreted.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ /** Exit statuses of the mode; a finished task keeps its last tool's status. */
16
+ const EXIT = Object.freeze({
17
+ USAGE: 64, // request text or option combination rejected
18
+ UNSUPPORTED: 65, // Formal AI reported that it did not understand the task
19
+ UNAVAILABLE: 69, // no qualified Formal AI runtime could be resolved
20
+ PROTOCOL: 70, // malformed response, unknown tool or unverified effect
21
+ REFUSED: 77, // a tool call was refused by the effect policy
22
+ CONFIG: 78, // invalid START_FORMAL_AI_* bound
23
+ BUDGET: 124, // step, tool, time or retry budget exhausted
24
+ });
25
+
26
+ const EFFECT_MODES = ['read-only', 'workspace-write'];
27
+
28
+ const BOUND_DEFAULTS = Object.freeze({
29
+ maxSteps: { env: 'START_FORMAL_AI_MAX_STEPS', value: 8, max: 64 },
30
+ maxRetries: { env: 'START_FORMAL_AI_MAX_RETRIES', value: 1, max: 5, min: 0 },
31
+ timeoutMs: { env: 'START_FORMAL_AI_TIMEOUT_MS', value: 120000 },
32
+ toolTimeoutMs: { env: 'START_FORMAL_AI_TOOL_TIMEOUT_MS', value: 30000 },
33
+ startupTimeoutMs: {
34
+ env: 'START_FORMAL_AI_STARTUP_TIMEOUT_MS',
35
+ value: 30000,
36
+ },
37
+ maxOutputBytes: {
38
+ env: 'START_FORMAL_AI_MAX_OUTPUT_BYTES',
39
+ value: 64 * 1024,
40
+ max: 16 * 1024 * 1024,
41
+ },
42
+ maxRequestChars: { env: 'START_FORMAL_AI_MAX_REQUEST_CHARS', value: 4096 },
43
+ maxMemoryMb: { env: 'START_FORMAL_AI_MAX_MEMORY_MB', value: 1024 },
44
+ maxFingerprintEntries: {
45
+ env: 'START_FORMAL_AI_MAX_FINGERPRINT_ENTRIES',
46
+ value: 5000,
47
+ },
48
+ });
49
+
50
+ /**
51
+ * Resolve the bounds of one run from START_FORMAL_AI_* variables.
52
+ * @param {object} [env] - Environment
53
+ * @returns {object} Bounds plus `effects`
54
+ * @throws {Error} With `exitCode` EXIT.CONFIG on an invalid value
55
+ */
56
+ function resolveBounds(env = process.env) {
57
+ const bounds = {};
58
+ for (const [key, spec] of Object.entries(BOUND_DEFAULTS)) {
59
+ const raw = env[spec.env];
60
+ if (raw === undefined || raw === '') {
61
+ bounds[key] = spec.value;
62
+ continue;
63
+ }
64
+ const min = spec.min === undefined ? 1 : spec.min;
65
+ const value = Number(raw);
66
+ if (
67
+ !/^\d+$/.test(raw.trim()) ||
68
+ value < min ||
69
+ (spec.max && value > spec.max)
70
+ ) {
71
+ throw configError(
72
+ `${spec.env} must be an integer from ${min} to ${spec.max || 'any'}, got "${raw}"`
73
+ );
74
+ }
75
+ bounds[key] = value;
76
+ }
77
+ const effects = (env.START_FORMAL_AI_EFFECTS || 'read-only').trim();
78
+ if (!EFFECT_MODES.includes(effects)) {
79
+ throw configError(
80
+ `START_FORMAL_AI_EFFECTS must be one of ${EFFECT_MODES.join(', ')}, got "${effects}"`
81
+ );
82
+ }
83
+ bounds.effects = effects;
84
+ return bounds;
85
+ }
86
+
87
+ function configError(message) {
88
+ return Object.assign(new Error(message), { exitCode: EXIT.CONFIG });
89
+ }
90
+
91
+ /**
92
+ * Split a command line with POSIX quoting rules. Characters that would make a
93
+ * shell do more than split words are reported instead of honoured.
94
+ * @param {string} line - Command line from a tool call
95
+ * @returns {{tokens: string[], problems: string[]}} Words and refused syntax
96
+ */
97
+ function tokenizeCommand(line) {
98
+ const tokens = [];
99
+ const problems = new Set();
100
+ let current = null;
101
+ let quote = null;
102
+ for (let i = 0; i < line.length; i++) {
103
+ const ch = line[i];
104
+ if (quote === "'") {
105
+ if (ch === "'") {
106
+ quote = null;
107
+ } else {
108
+ current += ch;
109
+ }
110
+ continue;
111
+ }
112
+ if (quote === '"') {
113
+ if (ch === '"') {
114
+ quote = null;
115
+ } else if (
116
+ ch === '\\' &&
117
+ i + 1 < line.length &&
118
+ '"\\$`'.includes(line[i + 1])
119
+ ) {
120
+ current += line[++i];
121
+ } else if (ch === '$' || ch === '`') {
122
+ problems.add(`expansion "${ch}"`);
123
+ current += ch;
124
+ } else {
125
+ current += ch;
126
+ }
127
+ continue;
128
+ }
129
+ if (ch === ' ' || ch === '\t') {
130
+ if (current !== null) {
131
+ tokens.push(current);
132
+ current = null;
133
+ }
134
+ continue;
135
+ }
136
+ if (ch === "'" || ch === '"') {
137
+ quote = ch;
138
+ current = current === null ? '' : current;
139
+ continue;
140
+ }
141
+ if (ch === '\\') {
142
+ if (i + 1 < line.length && line[i + 1] !== '\n') {
143
+ current = (current === null ? '' : current) + line[++i];
144
+ continue;
145
+ }
146
+ problems.add('line continuation');
147
+ continue;
148
+ }
149
+ if ('\n\r;&|<>()$`'.includes(ch)) {
150
+ problems.add(`shell operator "${ch === '\n' ? '\\n' : ch}"`);
151
+ } else if ('*?[]{}'.includes(ch)) {
152
+ problems.add(`unquoted pattern "${ch}"`);
153
+ } else if (ch === '~' && current === null) {
154
+ problems.add('home directory expansion "~"');
155
+ } else if (ch === '#' && current === null) {
156
+ problems.add('comment "#"');
157
+ }
158
+ current = (current === null ? '' : current) + ch;
159
+ }
160
+ if (quote) {
161
+ problems.add('unterminated quote');
162
+ }
163
+ if (current !== null) {
164
+ tokens.push(current);
165
+ }
166
+ return { tokens, problems: [...problems] };
167
+ }
168
+
169
+ const READ_PROGRAMS = new Set([
170
+ 'basename',
171
+ 'cat',
172
+ 'cmp',
173
+ 'diff',
174
+ 'dirname',
175
+ 'du',
176
+ 'file',
177
+ 'find',
178
+ 'git',
179
+ 'grep',
180
+ 'head',
181
+ 'ls',
182
+ 'md5sum',
183
+ 'pwd',
184
+ 'readlink',
185
+ 'realpath',
186
+ 'sha256sum',
187
+ 'sort',
188
+ 'stat',
189
+ 'tail',
190
+ 'test',
191
+ 'tree',
192
+ 'uniq',
193
+ 'wc',
194
+ ]);
195
+ const WRITE_PROGRAMS = new Set(['cp', 'mkdir', 'mv', 'rm', 'rmdir', 'touch']);
196
+ const LISTING_PROGRAMS = new Set(['ls', 'find', 'tree', 'du']);
197
+
198
+ /** Flags that turn an otherwise read-only program into a writer or waiter. */
199
+ const FORBIDDEN_FLAGS = {
200
+ find: /^-(delete|exec|execdir|ok|okdir|fprint|fprint0|fprintf|fls)$/,
201
+ sort: /^(-o.*|--output.*)$/,
202
+ tree: /^(-o.*|--output.*)$/,
203
+ file: /^(-C|--compile)$/,
204
+ tail: /^(-f|-F|--follow.*|--retry)$/,
205
+ git: /^(--output.*|--ext-diff|-c|--exec-path.*|--git-dir.*|--work-tree.*)$/,
206
+ rm: /^(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive|--no-preserve-root)$/,
207
+ cp: /^(-[a-zA-Z]*[l][a-zA-Z]*|--link|-s|--symbolic-link)$/,
208
+ };
209
+ const GIT_SUBCOMMANDS = new Set([
210
+ 'branch',
211
+ 'diff',
212
+ 'log',
213
+ 'ls-files',
214
+ 'rev-parse',
215
+ 'show',
216
+ 'status',
217
+ ]);
218
+ const GIT_BRANCH_FLAGS = new Set([
219
+ '-a',
220
+ '-r',
221
+ '-v',
222
+ '-vv',
223
+ '--list',
224
+ '--show-current',
225
+ ]);
226
+
227
+ function isInside(root, target) {
228
+ const relative = path.relative(root, target);
229
+ return (
230
+ relative === '' ||
231
+ (!relative.startsWith('..') && !path.isAbsolute(relative))
232
+ );
233
+ }
234
+
235
+ /** Real path of `target`, resolving its nearest existing ancestor. */
236
+ function realTarget(target) {
237
+ let existing = target;
238
+ const rest = [];
239
+ while (!fs.existsSync(existing)) {
240
+ const parent = path.dirname(existing);
241
+ if (parent === existing) {
242
+ return target;
243
+ }
244
+ rest.unshift(path.basename(existing));
245
+ existing = parent;
246
+ }
247
+ return path.join(fs.realpathSync(existing), ...rest);
248
+ }
249
+
250
+ /** Path-like operands of a call (values of `--opt=value` included). */
251
+ function operands(tokens) {
252
+ const values = [];
253
+ for (const token of tokens.slice(1)) {
254
+ if (token.startsWith('-')) {
255
+ const eq = token.indexOf('=');
256
+ if (eq > 0 && token.startsWith('--')) {
257
+ values.push(token.slice(eq + 1));
258
+ }
259
+ continue;
260
+ }
261
+ values.push(token);
262
+ }
263
+ return values;
264
+ }
265
+
266
+ /**
267
+ * Normalized workspace-relative path of an operand, or null outside it.
268
+ * @param {string} cwd - Working directory
269
+ * @param {string} operand - Operand text
270
+ * @returns {string|null}
271
+ */
272
+ function workspacePath(cwd, operand) {
273
+ const root = fs.realpathSync(cwd);
274
+ const resolved = realTarget(path.resolve(cwd, operand));
275
+ if (!isInside(root, resolved)) {
276
+ return null;
277
+ }
278
+ return path.relative(root, resolved) || '.';
279
+ }
280
+
281
+ /**
282
+ * Decide whether a tool call may run.
283
+ * @param {string} line - Command line of the call
284
+ * @param {object} context - {cwd, effects, observed: Set<string>, observedDirs: Set<string>}
285
+ * @returns {{allowed: boolean, reason?: string, tokens: string[], kind?: string, targets?: string[]}}
286
+ */
287
+ function evaluateCommand(line, context) {
288
+ const { tokens, problems } = tokenizeCommand(line);
289
+ const refuse = (reason) => ({ allowed: false, reason, tokens });
290
+ if (problems.length) {
291
+ return refuse(
292
+ `needs shell syntax start does not run: ${problems.join(', ')}`
293
+ );
294
+ }
295
+ if (!tokens.length) {
296
+ return refuse('empty command');
297
+ }
298
+ const program = tokens[0];
299
+ const isWrite = WRITE_PROGRAMS.has(program);
300
+ if (!READ_PROGRAMS.has(program) && !isWrite) {
301
+ return refuse(`program "${program}" is not in the supported tool set`);
302
+ }
303
+ if (isWrite && context.effects !== 'workspace-write') {
304
+ return refuse(
305
+ `"${program}" changes files; the effect mode is read-only (set START_FORMAL_AI_EFFECTS=workspace-write to allow workspace changes)`
306
+ );
307
+ }
308
+ const forbidden = FORBIDDEN_FLAGS[program];
309
+ const flag = forbidden && tokens.slice(1).find((t) => forbidden.test(t));
310
+ if (flag) {
311
+ return refuse(`option "${flag}" of ${program} is not allowed`);
312
+ }
313
+ if (program === 'git') {
314
+ const sub = tokens[1];
315
+ if (!GIT_SUBCOMMANDS.has(sub)) {
316
+ return refuse(`git subcommand "${sub || ''}" is not read-only`);
317
+ }
318
+ if (
319
+ sub === 'branch' &&
320
+ tokens.slice(2).some((t) => !GIT_BRANCH_FLAGS.has(t))
321
+ ) {
322
+ return refuse('git branch may only list branches');
323
+ }
324
+ }
325
+ const targets = [];
326
+ for (const operand of operands(tokens)) {
327
+ if (operand.startsWith('~')) {
328
+ return refuse(`"${operand}" is outside the working directory`);
329
+ }
330
+ // Absolute, escaping and symlinked-out operands are refused: reads and
331
+ // changes stay inside the working directory.
332
+ const relative = workspacePath(context.cwd, operand);
333
+ if (relative === null) {
334
+ return refuse(`"${operand}" is outside the working directory`);
335
+ }
336
+ targets.push(relative);
337
+ }
338
+ if (isWrite) {
339
+ if (!targets.length) {
340
+ return refuse(`${program} needs an explicit target`);
341
+ }
342
+ const unobserved = targets.filter(
343
+ (t) =>
344
+ t === '.' ||
345
+ !(context.observed.has(t) || context.observedDirs.has(path.dirname(t)))
346
+ );
347
+ if (unobserved.length) {
348
+ return refuse(
349
+ `target ${unobserved.map((t) => `"${t}"`).join(', ')} was not observed before the change`
350
+ );
351
+ }
352
+ }
353
+ return { allowed: true, tokens, kind: isWrite ? 'write' : 'read', targets };
354
+ }
355
+
356
+ /**
357
+ * Remember what a read call observed, so a later change may name it.
358
+ * @param {object} decision - evaluateCommand() result of the read call
359
+ * @param {object} context - Same context object
360
+ */
361
+ function recordObservation(decision, context) {
362
+ const listed = decision.targets.length ? decision.targets : ['.'];
363
+ for (const target of decision.targets) {
364
+ context.observed.add(target);
365
+ }
366
+ if (LISTING_PROGRAMS.has(decision.tokens[0])) {
367
+ for (const target of listed) {
368
+ context.observedDirs.add(target);
369
+ }
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Bounded fingerprint of the working directory (names, types, sizes, mtimes).
375
+ * @param {string} cwd - Directory
376
+ * @param {number} limit - Maximum entries
377
+ * @returns {{entries: Map<string,string>, complete: boolean}}
378
+ */
379
+ function fingerprint(cwd, limit) {
380
+ const entries = new Map();
381
+ const queue = [''];
382
+ while (queue.length) {
383
+ const relative = queue.shift();
384
+ let names;
385
+ try {
386
+ names = fs.readdirSync(path.join(cwd, relative)).sort();
387
+ } catch {
388
+ continue;
389
+ }
390
+ for (const name of names) {
391
+ if (entries.size >= limit) {
392
+ return { entries, complete: false };
393
+ }
394
+ const child = relative ? `${relative}/${name}` : name;
395
+ let stat;
396
+ try {
397
+ stat = fs.lstatSync(path.join(cwd, child));
398
+ } catch {
399
+ continue;
400
+ }
401
+ const type = stat.isDirectory() ? 'd' : stat.isSymbolicLink() ? 'l' : 'f';
402
+ entries.set(child, `${type}:${stat.size}:${stat.mtimeMs}`);
403
+ if (type === 'd' && name !== '.git') {
404
+ queue.push(child);
405
+ }
406
+ }
407
+ }
408
+ return { entries, complete: true };
409
+ }
410
+
411
+ /**
412
+ * Differences between two fingerprints.
413
+ * @returns {{added: string[], removed: string[], changed: string[], complete: boolean}}
414
+ */
415
+ function diffFingerprints(before, after) {
416
+ const added = [];
417
+ const removed = [];
418
+ const changed = [];
419
+ for (const [name, value] of after.entries) {
420
+ if (!before.entries.has(name)) {
421
+ added.push(name);
422
+ } else if (before.entries.get(name) !== value) {
423
+ changed.push(name);
424
+ }
425
+ }
426
+ for (const name of before.entries.keys()) {
427
+ if (!after.entries.has(name)) {
428
+ removed.push(name);
429
+ }
430
+ }
431
+ return {
432
+ added,
433
+ removed,
434
+ changed,
435
+ complete: before.complete && after.complete,
436
+ };
437
+ }
438
+
439
+ function hasEffects(diff) {
440
+ return diff.added.length + diff.removed.length + diff.changed.length > 0;
441
+ }
442
+
443
+ /**
444
+ * Read the result of a successful change back from the filesystem.
445
+ * @param {string[]} tokens - Argv of the change
446
+ * @param {string} cwd - Working directory
447
+ * @returns {{verified: boolean, detail: string}}
448
+ */
449
+ function verifyChange(tokens, cwd) {
450
+ const program = tokens[0];
451
+ const args = operands(tokens);
452
+ const exists = (p) => fs.existsSync(path.resolve(cwd, p));
453
+ const isDir = (p) =>
454
+ exists(p) && fs.statSync(path.resolve(cwd, p)).isDirectory();
455
+ let missing = [];
456
+ if (program === 'mkdir') {
457
+ missing = args.filter((p) => !isDir(p));
458
+ } else if (program === 'touch') {
459
+ missing = args.filter((p) => !exists(p));
460
+ } else if (program === 'rm' || program === 'rmdir') {
461
+ missing = args.filter((p) => exists(p)).map((p) => `${p} (still present)`);
462
+ } else if (program === 'cp' || program === 'mv') {
463
+ const dest = args[args.length - 1];
464
+ const sources = args.slice(0, -1);
465
+ const expected =
466
+ isDir(dest) && sources.length
467
+ ? sources.map((s) => path.join(dest, path.basename(s)))
468
+ : [dest];
469
+ missing = expected.filter((p) => !exists(p));
470
+ if (program === 'mv') {
471
+ missing.push(
472
+ ...sources
473
+ .filter((s) => exists(s) && !expected.includes(s))
474
+ .map((s) => `${s} (still present)`)
475
+ );
476
+ }
477
+ }
478
+ return missing.length
479
+ ? { verified: false, detail: `readback failed: ${missing.join(', ')}` }
480
+ : {
481
+ verified: true,
482
+ detail: `readback confirmed ${program} ${args.join(' ')}`,
483
+ };
484
+ }
485
+
486
+ module.exports = {
487
+ BOUND_DEFAULTS,
488
+ EFFECT_MODES,
489
+ EXIT,
490
+ READ_PROGRAMS,
491
+ WRITE_PROGRAMS,
492
+ diffFingerprints,
493
+ evaluateCommand,
494
+ fingerprint,
495
+ hasEffects,
496
+ recordObservation,
497
+ resolveBounds,
498
+ tokenizeCommand,
499
+ verifyChange,
500
+ workspacePath,
501
+ };