mustflow 2.17.0 → 2.18.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.
@@ -7,7 +7,7 @@ import { readTomlFile } from '../toml.js';
7
7
  import { collectSourceAnchorIndexRecords, hasHighRiskSourceAnchorRiskTags, } from '../../../core/source-anchor-status.js';
8
8
  import { normalizeCommandEffects } from '../../../core/command-effects.js';
9
9
  import { listChangeClassificationRuleDescriptors } from '../../../core/change-classification.js';
10
- import { DEFAULT_DATABASE_RELATIVE_PATH, DEFAULT_PROMPT_CACHE_STABLE_READ, DEFAULT_PROMPT_CACHE_TASK_SOURCES, DEFAULT_PROMPT_CACHE_VOLATILE_SOURCES, INDEX_CONFIG_RELATIVE_PATH, LOCAL_INDEX_CONTENT_MODE, LOCAL_INDEX_EXCLUDED_RAW_DATA_KINDS, LOCAL_INDEX_PARSER_VERSION, LOCAL_INDEX_SCHEMA_VERSION, LOCAL_INDEX_STORE_FULL_CONTENT, LATEST_RUN_STATE_RELATIVE_PATH, MAX_SEARCH_MATCH_SNIPPET_CHARS, MAX_SNIPPET_BYTES_PER_DOCUMENT, MUSTFLOW_RELATIVE_PATH, SEARCH_BACKEND_FTS5, SEARCH_BACKEND_TABLE_SCAN, SEARCH_MATCH_CONTEXT_AFTER_CHARS, SEARCH_MATCH_CONTEXT_BEFORE_CHARS, SEARCH_MATCH_TRUNCATION_MARKER, SEARCH_NGRAM_MAX_LENGTH, SEARCH_NGRAM_MIN_LENGTH, TEST_DISABLE_FTS5_ENV, } from './constants.js';
10
+ import { DEFAULT_DATABASE_RELATIVE_PATH, DEFAULT_PROMPT_CACHE_STABLE_READ, DEFAULT_PROMPT_CACHE_TASK_SOURCES, DEFAULT_PROMPT_CACHE_VOLATILE_SOURCES, INDEX_CONFIG_RELATIVE_PATH, LOCAL_INDEX_CONTENT_MODE, LOCAL_INDEX_EXCLUDED_RAW_DATA_KINDS, LOCAL_INDEX_PARSER_VERSION, LOCAL_INDEX_SCHEMA_VERSION, LOCAL_INDEX_STORE_FULL_CONTENT, LATEST_RUN_STATE_RELATIVE_PATH, MAX_SEARCH_MATCH_SNIPPET_CHARS, MAX_SNIPPET_BYTES_PER_DOCUMENT, MUSTFLOW_RELATIVE_PATH, SEARCH_BACKEND_FTS5, SEARCH_BACKEND_TABLE_SCAN, SEARCH_MATCH_CONTEXT_AFTER_CHARS, SEARCH_MATCH_CONTEXT_BEFORE_CHARS, SEARCH_MATCH_TRUNCATION_MARKER, SEARCH_NGRAM_MAX_GRAMS_PER_TARGET, SEARCH_NGRAM_MAX_LENGTH, SEARCH_NGRAM_MAX_TOKEN_CHARS, SEARCH_NGRAM_MIN_LENGTH, SOURCE_INDEX_MAX_FILE_BYTES, TEST_DISABLE_FTS5_ENV, } from './constants.js';
11
11
  import { loadSqlJs } from './sql.js';
12
12
  export function getLocalIndexDatabasePath(projectRoot) {
13
13
  return path.join(projectRoot, ...DEFAULT_DATABASE_RELATIVE_PATH.split('/'));
@@ -83,11 +83,12 @@ function readPositiveInteger(table, key) {
83
83
  }
84
84
  function readLocalIndexSourceConfig(projectRoot) {
85
85
  const sourceIndexTable = readNestedTable(readIndexToml(projectRoot), 'source_index');
86
+ const configuredMaxFileBytes = readPositiveInteger(sourceIndexTable, 'max_file_bytes');
86
87
  return {
87
88
  enabledByDefault: readBoolean(sourceIndexTable, 'enabled_by_default') === true,
88
89
  include: readOptionalStringArray(sourceIndexTable, 'include') ?? [],
89
90
  exclude: readOptionalStringArray(sourceIndexTable, 'exclude') ?? [],
90
- maxFileBytes: readPositiveInteger(sourceIndexTable, 'max_file_bytes'),
91
+ maxFileBytes: Math.min(configuredMaxFileBytes ?? SOURCE_INDEX_MAX_FILE_BYTES, SOURCE_INDEX_MAX_FILE_BYTES),
91
92
  allowedExtensions: readOptionalStringArray(sourceIndexTable, 'allowed_extensions') ?? [],
92
93
  };
93
94
  }
@@ -321,10 +322,14 @@ function buildSearchNgrams(values) {
321
322
  const grams = new Set();
322
323
  for (const value of values) {
323
324
  for (const token of extractSearchTokens(value)) {
324
- const maxLength = Math.min(SEARCH_NGRAM_MAX_LENGTH, token.length);
325
+ const boundedToken = token.slice(0, SEARCH_NGRAM_MAX_TOKEN_CHARS);
326
+ const maxLength = Math.min(SEARCH_NGRAM_MAX_LENGTH, boundedToken.length);
325
327
  for (let length = SEARCH_NGRAM_MIN_LENGTH; length <= maxLength; length += 1) {
326
- for (let index = 0; index <= token.length - length; index += 1) {
327
- grams.add(token.slice(index, index + length));
328
+ for (let index = 0; index <= boundedToken.length - length; index += 1) {
329
+ grams.add(boundedToken.slice(index, index + length));
330
+ if (grams.size >= SEARCH_NGRAM_MAX_GRAMS_PER_TARGET) {
331
+ return [...grams].sort((left, right) => left.localeCompare(right));
332
+ }
328
333
  }
329
334
  }
330
335
  }
@@ -1650,6 +1655,18 @@ function populateDatabase(database, capabilities, documents, skills, skillRoutes
1650
1655
  'max_snippet_bytes_per_document',
1651
1656
  String(MAX_SNIPPET_BYTES_PER_DOCUMENT),
1652
1657
  ]);
1658
+ database.run('INSERT INTO metadata (key, value) VALUES (?, ?)', [
1659
+ 'search_ngram_max_token_chars',
1660
+ String(SEARCH_NGRAM_MAX_TOKEN_CHARS),
1661
+ ]);
1662
+ database.run('INSERT INTO metadata (key, value) VALUES (?, ?)', [
1663
+ 'search_ngram_max_grams_per_target',
1664
+ String(SEARCH_NGRAM_MAX_GRAMS_PER_TARGET),
1665
+ ]);
1666
+ database.run('INSERT INTO metadata (key, value) VALUES (?, ?)', [
1667
+ 'source_index_max_file_bytes',
1668
+ String(SOURCE_INDEX_MAX_FILE_BYTES),
1669
+ ]);
1653
1670
  database.run('INSERT INTO metadata (key, value) VALUES (?, ?)', [
1654
1671
  'excluded_raw_data_kinds',
1655
1672
  LOCAL_INDEX_EXCLUDED_RAW_DATA_KINDS.join(','),
@@ -1,8 +1,8 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { existsSync, readdirSync, statSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, lstatSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
4
4
  import path from 'node:path';
5
- import { listFilesRecursive, toPosixPath } from './filesystem.js';
5
+ import { toPosixPath } from './filesystem.js';
6
6
  import { readTomlFile } from './toml.js';
7
7
  const DEFAULT_DEPTH = 3;
8
8
  const REPO_MAP_DOC_ID = 'repo-map';
@@ -241,7 +241,7 @@ function getRepoMapConfig(projectRoot) {
241
241
  };
242
242
  }
243
243
  function getGitFiles(projectRoot) {
244
- const result = spawnSync('git', ['ls-files'], {
244
+ const result = spawnSync('git', ['ls-files', '-z'], {
245
245
  cwd: projectRoot,
246
246
  encoding: 'utf8',
247
247
  });
@@ -249,16 +249,43 @@ function getGitFiles(projectRoot) {
249
249
  return [];
250
250
  }
251
251
  return result.stdout
252
- .split(/\r?\n/)
253
- .map((line) => line.trim())
252
+ .split('\0')
253
+ .map((line) => toPosixPath(line))
254
254
  .filter(Boolean);
255
255
  }
256
- function getRepositoryFiles(projectRoot) {
256
+ function isAnchorCandidatePath(relativePath, priorityPaths) {
257
+ return priorityPaths.has(relativePath) || Boolean(getAnchorDescription(relativePath));
258
+ }
259
+ function listAnchorCandidateFilesRecursive(rootPath, depth, priorityPaths) {
260
+ const results = [];
261
+ function visit(currentPath, directoryDepth) {
262
+ for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
263
+ const entryPath = path.join(currentPath, entry.name);
264
+ const relativePath = toPosixPath(path.relative(rootPath, entryPath));
265
+ if (entry.isDirectory()) {
266
+ if (EXCLUDED_SEGMENTS.has(entry.name) || directoryDepth >= depth) {
267
+ continue;
268
+ }
269
+ visit(entryPath, directoryDepth + 1);
270
+ continue;
271
+ }
272
+ if (entry.isFile() && isAnchorCandidatePath(relativePath, priorityPaths)) {
273
+ results.push(relativePath);
274
+ }
275
+ }
276
+ }
277
+ if (!existsSync(rootPath) || !statSync(rootPath).isDirectory()) {
278
+ return [];
279
+ }
280
+ visit(rootPath, 0);
281
+ return results.sort();
282
+ }
283
+ function getRepositoryFiles(projectRoot, depth, priorityPaths) {
257
284
  const files = new Set();
258
285
  for (const relativePath of getGitFiles(projectRoot)) {
259
286
  files.add(relativePath);
260
287
  }
261
- for (const relativePath of listFilesRecursive(projectRoot, { ignoredDirectoryNames: EXCLUDED_SEGMENTS })) {
288
+ for (const relativePath of listAnchorCandidateFilesRecursive(projectRoot, depth, priorityPaths)) {
262
289
  files.add(relativePath);
263
290
  }
264
291
  return Array.from(files);
@@ -306,7 +333,7 @@ function isUnderExcludedPrefix(relativePath, excludedPrefixes) {
306
333
  return excludedPrefixes.some((prefix) => relativePath === prefix.slice(0, -1) || relativePath.startsWith(prefix));
307
334
  }
308
335
  function discoverAnchors(projectRoot, depth, priorityPaths, nestedRepositories, excludedPrefixes) {
309
- return getRepositoryFiles(projectRoot)
336
+ return getRepositoryFiles(projectRoot, depth, priorityPaths)
310
337
  .filter(shouldIncludePath)
311
338
  .filter((relativePath) => !isUnderNestedRepository(relativePath, nestedRepositories))
312
339
  .filter((relativePath) => !isUnderExcludedPrefix(relativePath, excludedPrefixes))
@@ -350,13 +377,9 @@ function renderDirectoryAnchors(anchors) {
350
377
  function hasGitMarker(directoryPath) {
351
378
  return existsSync(path.join(directoryPath, '.git'));
352
379
  }
353
- function isDirectory(directoryPath) {
354
- try {
355
- return statSync(directoryPath).isDirectory();
356
- }
357
- catch {
358
- return false;
359
- }
380
+ function isRealPathInside(parentRealPath, childRealPath) {
381
+ const relative = path.relative(parentRealPath, childRealPath);
382
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
360
383
  }
361
384
  function isSafeWorkspaceRoot(projectRoot, workspaceRoot) {
362
385
  const absoluteRoot = path.resolve(projectRoot, workspaceRoot);
@@ -371,6 +394,32 @@ function getWorkspaceRootPrefixes(projectRoot, workspaceConfig) {
371
394
  .filter((workspaceRoot) => isSafeWorkspaceRoot(projectRoot, workspaceRoot))
372
395
  .map((workspaceRoot) => `${toPosixPath(workspaceRoot).replace(/\/+$/, '')}/`);
373
396
  }
397
+ function resolveSafeDirectoryTarget(projectRootRealPath, logicalPath, followSymlinks) {
398
+ try {
399
+ const stats = lstatSync(logicalPath);
400
+ if (stats.isSymbolicLink()) {
401
+ if (!followSymlinks) {
402
+ return undefined;
403
+ }
404
+ const realPath = realpathSync(logicalPath);
405
+ if (!isRealPathInside(projectRootRealPath, realPath) || !statSync(realPath).isDirectory()) {
406
+ return undefined;
407
+ }
408
+ return { logicalPath, realPath };
409
+ }
410
+ if (!stats.isDirectory()) {
411
+ return undefined;
412
+ }
413
+ const realPath = realpathSync(logicalPath);
414
+ if (!isRealPathInside(projectRootRealPath, realPath)) {
415
+ return undefined;
416
+ }
417
+ return { logicalPath, realPath };
418
+ }
419
+ catch {
420
+ return undefined;
421
+ }
422
+ }
374
423
  function collectNestedRepository(projectRoot, repositoryPath, anchorFiles) {
375
424
  const relativeRoot = `${toPosixPath(path.relative(projectRoot, repositoryPath))}/`;
376
425
  const existingAnchors = new Set();
@@ -422,31 +471,34 @@ function discoverNestedRepositories(projectRoot, mapConfig, workspaceConfig) {
422
471
  }
423
472
  const repositories = [];
424
473
  const seenRepositoryPaths = new Set();
425
- function visit(directoryPath, depth) {
474
+ const seenDirectoryPaths = new Set();
475
+ const projectRootRealPath = realpathSync(projectRoot);
476
+ function visit(directoryTarget, depth) {
426
477
  if (repositories.length >= workspaceConfig.maxRepositories || depth > workspaceConfig.maxDepth) {
427
478
  return;
428
479
  }
429
- if (hasGitMarker(directoryPath)) {
430
- const resolvedRepositoryPath = path.resolve(directoryPath);
480
+ if (seenDirectoryPaths.has(directoryTarget.realPath)) {
481
+ return;
482
+ }
483
+ seenDirectoryPaths.add(directoryTarget.realPath);
484
+ if (hasGitMarker(directoryTarget.logicalPath)) {
485
+ const resolvedRepositoryPath = directoryTarget.realPath;
431
486
  if (!seenRepositoryPaths.has(resolvedRepositoryPath)) {
432
487
  seenRepositoryPaths.add(resolvedRepositoryPath);
433
- repositories.push(collectNestedRepository(projectRoot, resolvedRepositoryPath, mapConfig.anchorFiles));
488
+ repositories.push(collectNestedRepository(projectRoot, directoryTarget.logicalPath, mapConfig.anchorFiles));
434
489
  }
435
490
  if (workspaceConfig.stopAtRepositoryRoot) {
436
491
  return;
437
492
  }
438
493
  }
439
- for (const entry of readdirSync(directoryPath, { withFileTypes: true })) {
440
- if (!entry.isDirectory()) {
441
- continue;
442
- }
494
+ for (const entry of readdirSync(directoryTarget.logicalPath, { withFileTypes: true })) {
443
495
  if (EXCLUDED_SEGMENTS.has(entry.name)) {
444
496
  continue;
445
497
  }
446
- if (entry.isSymbolicLink() && !workspaceConfig.followSymlinks) {
447
- continue;
498
+ const childDirectoryTarget = resolveSafeDirectoryTarget(projectRootRealPath, path.join(directoryTarget.logicalPath, entry.name), workspaceConfig.followSymlinks);
499
+ if (childDirectoryTarget) {
500
+ visit(childDirectoryTarget, depth + 1);
448
501
  }
449
- visit(path.join(directoryPath, entry.name), depth + 1);
450
502
  }
451
503
  }
452
504
  for (const workspaceRoot of workspaceConfig.roots) {
@@ -454,10 +506,11 @@ function discoverNestedRepositories(projectRoot, mapConfig, workspaceConfig) {
454
506
  continue;
455
507
  }
456
508
  const absoluteWorkspaceRoot = path.resolve(projectRoot, workspaceRoot);
457
- if (!isDirectory(absoluteWorkspaceRoot)) {
509
+ const workspaceTarget = resolveSafeDirectoryTarget(projectRootRealPath, absoluteWorkspaceRoot, workspaceConfig.followSymlinks);
510
+ if (!workspaceTarget) {
458
511
  continue;
459
512
  }
460
- visit(absoluteWorkspaceRoot, 0);
513
+ visit(workspaceTarget, 0);
461
514
  }
462
515
  return repositories.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
463
516
  }
@@ -4,6 +4,7 @@ import { resolveSafeProjectCwd } from '../../core/command-cwd.js';
4
4
  import { resolveCommandEnv } from '../../core/command-env.js';
5
5
  import { evaluateCommandIntentEligibility, } from '../../core/command-intent-eligibility.js';
6
6
  import { isRecord, readPositiveInteger, readString, readStringArray, } from '../../core/config-loading.js';
7
+ import { DEFAULT_COMMAND_MAX_OUTPUT_BYTES, MAX_COMMAND_OUTPUT_BYTES, commandMaxOutputBytesLimitMessage, } from '../../core/command-output-limits.js';
7
8
  import { t } from './i18n.js';
8
9
  function getSuccessExitCodes(intent) {
9
10
  const value = intent.success_exit_codes;
@@ -72,6 +73,24 @@ function getRunPlanMode(commandArgv, intent) {
72
73
  }
73
74
  return intent.mode === 'shell' ? 'shell' : null;
74
75
  }
76
+ function readEffectiveMaxOutputBytes(contract, intent) {
77
+ return readPositiveInteger(intent, 'max_output_bytes') ??
78
+ readPositiveInteger(contract.defaults, 'max_output_bytes') ??
79
+ DEFAULT_COMMAND_MAX_OUTPUT_BYTES;
80
+ }
81
+ function getMaxOutputBytesLimitDetail(contract, intent) {
82
+ const intentValue = readPositiveInteger(intent, 'max_output_bytes');
83
+ if (intentValue !== undefined) {
84
+ return intentValue > MAX_COMMAND_OUTPUT_BYTES ?
85
+ commandMaxOutputBytesLimitMessage('[commands.intents.<intent>].max_output_bytes') :
86
+ null;
87
+ }
88
+ const defaultValue = readPositiveInteger(contract.defaults, 'max_output_bytes');
89
+ if (defaultValue !== undefined && defaultValue > MAX_COMMAND_OUTPUT_BYTES) {
90
+ return commandMaxOutputBytesLimitMessage('[commands.defaults].max_output_bytes');
91
+ }
92
+ return null;
93
+ }
75
94
  function readRunIntentMetadata(contract, intent) {
76
95
  const configuredCwd = readString(intent, 'cwd') ?? readString(contract.defaults, 'default_cwd') ?? '.';
77
96
  const commandArgv = readStringArray(intent, 'argv');
@@ -84,7 +103,7 @@ function readRunIntentMetadata(contract, intent) {
84
103
  kind: readString(intent, 'kind') ?? null,
85
104
  configuredCwd,
86
105
  timeoutSeconds: readPositiveInteger(intent, 'timeout_seconds') ?? null,
87
- maxOutputBytes: readPositiveInteger(intent, 'max_output_bytes') ?? readPositiveInteger(contract.defaults, 'max_output_bytes') ?? 1_048_576,
106
+ maxOutputBytes: readEffectiveMaxOutputBytes(contract, intent),
88
107
  successExitCodes: getSuccessExitCodes(intent),
89
108
  commandArgv,
90
109
  shellCommand,
@@ -149,6 +168,10 @@ export function createRunPlan(projectRoot, contract, intentName, options = {}) {
149
168
  return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, eligibility.code, eligibility.detail);
150
169
  }
151
170
  const metadata = readRunIntentMetadata(contract, rawIntent);
171
+ const maxOutputBytesLimitDetail = getMaxOutputBytesLimitDetail(contract, rawIntent);
172
+ if (maxOutputBytesLimitDetail) {
173
+ return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, 'max_output_bytes_exceeds_limit', maxOutputBytesLimitDetail);
174
+ }
152
175
  let cwd;
153
176
  try {
154
177
  cwd = resolveSafeProjectCwd(projectRoot, metadata.configuredCwd);
@@ -207,7 +230,7 @@ function createSuggestedIntentSnippet(intentName, metadata, reasonCode) {
207
230
  return null;
208
231
  }
209
232
  let commandLines;
210
- if (reasonCode === 'blocked_shell_background_pattern') {
233
+ if (reasonCode === 'blocked_shell_background_pattern' || reasonCode === 'blocked_long_running_command_pattern') {
211
234
  commandLines = [`argv = ${formatTomlStringArray(['TODO_REPLACE_WITH_FINITE_COMMAND'])}`];
212
235
  }
213
236
  else if (metadata?.shellCommand) {
@@ -605,7 +605,8 @@ function validateStrictVersionSources(projectRoot, preferencesToml, versioningTo
605
605
  pushStrictIssue(issues, '[release.versioning] is enabled but no version source was detected; add .mustflow/config/versioning.toml or a package/template version source');
606
606
  }
607
607
  function validateStrictTemplateVersionSync(projectRoot, preferencesToml, issues) {
608
- const changedPaths = existsSync(path.join(projectRoot, '.git')) ? readGitChangedFiles(projectRoot) : undefined;
608
+ const changedPathResult = existsSync(path.join(projectRoot, '.git')) ? readGitChangedFiles(projectRoot) : undefined;
609
+ const changedPaths = changedPathResult?.ok ? changedPathResult.files : undefined;
609
610
  for (const issue of validateTemplateVersionSync(projectRoot, preferencesToml, changedPaths)) {
610
611
  if (issue.severity === 'warning') {
611
612
  pushStrictWarning(issues, issue.message);
@@ -4,10 +4,12 @@ const CHECK_ISSUE_ID_RULES = [
4
4
  ['mustflow.command_contract.configured_missing_lifecycle', /^Configured intent [^\s]+ must define lifecycle$/u],
5
5
  ['mustflow.command_contract.configured_missing_run_policy', /^Configured intent [^\s]+ must define run_policy$/u],
6
6
  ['mustflow.command_contract.oneshot_missing_timeout', /^Oneshot intent [^\s]+ must define timeout_seconds$/u],
7
+ ['mustflow.command_contract.max_output_bytes_exceeds_limit', /^\[commands\.(?:defaults|intents\.[^\]]+)\]\.max_output_bytes must be less than or equal to \d+$/u],
7
8
  ['mustflow.command_contract.oneshot_stdin_not_closed', /^Oneshot intent [^\s]+ must set stdin = "closed"$/u],
8
9
  ['mustflow.command_contract.long_running_agent_allowed', /^Long-running intent [^\s]+ must not use run_policy = "agent_allowed"$/u],
9
10
  ['mustflow.command_contract.executable_source_missing', /^Configured intent [^\s]+ must define argv or mode = "shell" with cmd$/u],
10
11
  ['mustflow.command_contract.shell_background_pattern', /^Shell intent [^\s]+ contains a blocked long-running or background pattern$/u],
12
+ ['mustflow.command_contract.long_running_command_pattern', /^Intent [^\s]+ contains a blocked long-running or background command pattern$/u],
11
13
  ['mustflow.command_contract.success_exit_codes_invalid', /^\[commands\.intents\.[^\]]+\]\.success_exit_codes must be an integer array$/u],
12
14
  ['mustflow.command_contract.effects_invalid', /^(?:Strict: )?(?:\[commands\.(?:resources|intents\.[^\]]+\.effects)[^\]]*\]|Command effect for intent [^\s]+ must define path, paths, or lock)/u],
13
15
  ['mustflow.command_contract.effect_path_escape', /^Strict: Command effect path must stay inside the current root:/u],
@@ -1,16 +1,39 @@
1
+ import path from 'node:path';
1
2
  import { readString, readStringArray } from './config-loading.js';
2
3
  const SAFE_COMMAND_INTENT_NAME_PATTERN = /^[A-Za-z0-9_-]+$/u;
4
+ const SHELL_WRAPPER_COMMANDS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'cmd', 'powershell', 'pwsh']);
5
+ const SHELL_EVALUATION_FLAGS = new Set(['-c', '/c', '-command', '-commandwithargs']);
6
+ const INTERPRETER_EVALUATION_FLAGS = new Map([
7
+ ['node', new Set(['-e', '--eval'])],
8
+ ['python', new Set(['-c'])],
9
+ ['python3', new Set(['-c'])],
10
+ ['py', new Set(['-c'])],
11
+ ['ruby', new Set(['-e'])],
12
+ ['perl', new Set(['-e'])],
13
+ ]);
14
+ const PACKAGE_SCRIPT_RUNNERS = new Set(['bun', 'npm', 'pnpm', 'yarn']);
15
+ const LONG_RUNNING_PACKAGE_SCRIPTS = new Set(['dev', 'start', 'serve', 'watch', 'preview']);
16
+ const LONG_RUNNING_EXECUTABLES = new Set(['nodemon', 'pm2', 'serve', 'http-server', 'live-server', 'webpack-dev-server']);
3
17
  export const BACKGROUND_SHELL_PATTERNS = [
4
- /\s&\s*$/u,
18
+ /(?:^|[^&])&(?!&)\s*$/u,
5
19
  /\bnohup\b/iu,
6
20
  /\bdisown\b/iu,
7
21
  /\bStart-Process\b/iu,
8
- /\bstart\s+/iu,
22
+ /(?:^|[;&|]\s*)start\s+/iu,
9
23
  /\bxdg-open\b/iu,
10
24
  /\bopen\s+/iu,
11
25
  /\bchrome(?:\.exe)?\b/iu,
12
26
  /\bchromium(?:\.exe)?\b/iu,
13
27
  ];
28
+ export const LONG_RUNNING_COMMAND_TEXT_PATTERNS = [
29
+ /\b(?:npm|pnpm|bun|yarn)\s+(?:run\s+)?(?:dev|start|serve|watch|preview)\b/iu,
30
+ /\b(?:nohup|disown)\b/iu,
31
+ /(?:^|[^&])&(?!&)\s*$/u,
32
+ /\bsetInterval\s*\(/u,
33
+ /\bwhile\s*(?:\(\s*true\s*\)|true)\b/iu,
34
+ /\bserve_forever\s*\(/iu,
35
+ /\bcreateServer\s*\([^)]*\)\s*\.listen\s*\(/u,
36
+ ];
14
37
  export function commandIntentNameIsSafe(intentName) {
15
38
  return SAFE_COMMAND_INTENT_NAME_PATTERN.test(intentName);
16
39
  }
@@ -25,3 +48,82 @@ export function shellCommandHasBlockedBackgroundPattern(command) {
25
48
  export function commandIntentHasBlockedShellBackgroundPattern(intent) {
26
49
  return intent.mode === 'shell' && typeof intent.cmd === 'string' && shellCommandHasBlockedBackgroundPattern(intent.cmd);
27
50
  }
51
+ function normalizeExecutableName(value) {
52
+ return path.basename(value).replace(/\.(?:cmd|exe|ps1)$/iu, '').toLowerCase();
53
+ }
54
+ function findFlagPayload(argv, flags) {
55
+ for (let index = 1; index < argv.length - 1; index += 1) {
56
+ if (flags.has(argv[index].toLowerCase())) {
57
+ return argv[index + 1];
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+ function commandTextHasLongRunningPattern(command) {
63
+ return LONG_RUNNING_COMMAND_TEXT_PATTERNS.some((pattern) => pattern.test(command));
64
+ }
65
+ function readPackageScriptName(command, args) {
66
+ if (!PACKAGE_SCRIPT_RUNNERS.has(command)) {
67
+ return null;
68
+ }
69
+ if (args[0] === 'run' && args[1] && !args[1].startsWith('-')) {
70
+ return args[1];
71
+ }
72
+ if (args[0] && LONG_RUNNING_PACKAGE_SCRIPTS.has(args[0])) {
73
+ return args[0];
74
+ }
75
+ return null;
76
+ }
77
+ function argvHasBlockedLongRunningPattern(argv) {
78
+ const [rawCommand = '', ...args] = argv;
79
+ const command = normalizeExecutableName(rawCommand);
80
+ const shellPayload = SHELL_WRAPPER_COMMANDS.has(command) ? findFlagPayload(argv, SHELL_EVALUATION_FLAGS) : null;
81
+ if (shellPayload && (shellCommandHasBlockedBackgroundPattern(shellPayload) || commandTextHasLongRunningPattern(shellPayload))) {
82
+ return `shell wrapper payload contains a blocked long-running or background pattern: ${shellPayload}`;
83
+ }
84
+ const interpreterFlags = INTERPRETER_EVALUATION_FLAGS.get(command);
85
+ const interpreterPayload = interpreterFlags ? findFlagPayload(argv, interpreterFlags) : null;
86
+ if (interpreterPayload && commandTextHasLongRunningPattern(interpreterPayload)) {
87
+ return `interpreter evaluation payload contains a blocked long-running pattern: ${interpreterPayload}`;
88
+ }
89
+ const packageScriptName = readPackageScriptName(command, args);
90
+ if (packageScriptName && LONG_RUNNING_PACKAGE_SCRIPTS.has(packageScriptName)) {
91
+ return `package-manager script "${packageScriptName}" is commonly long-running`;
92
+ }
93
+ if (LONG_RUNNING_EXECUTABLES.has(command)) {
94
+ return `executable "${command}" is commonly long-running`;
95
+ }
96
+ if (command === 'vite' && !args.includes('build')) {
97
+ return 'vite without build is commonly a development server';
98
+ }
99
+ if (command === 'next' && ['dev', 'start'].includes(args[0] ?? '')) {
100
+ return `next ${args[0]} is commonly long-running`;
101
+ }
102
+ if (command === 'webpack' && (args.includes('--watch') || args.includes('-w') || args.includes('serve'))) {
103
+ return 'webpack watch or serve mode is commonly long-running';
104
+ }
105
+ if (command === 'tsc' && (args.includes('--watch') || args.includes('-w'))) {
106
+ return 'tsc watch mode is long-running';
107
+ }
108
+ return null;
109
+ }
110
+ export function commandIntentBlockedCommandPattern(intent) {
111
+ if (intent.mode === 'shell' && typeof intent.cmd === 'string' && shellCommandHasBlockedBackgroundPattern(intent.cmd)) {
112
+ return {
113
+ code: 'shell_background_pattern',
114
+ detail: 'Shell command contains a blocked long-running or background pattern.',
115
+ };
116
+ }
117
+ const argv = readStringArray(intent, 'argv');
118
+ if (!argv) {
119
+ return null;
120
+ }
121
+ const detail = argvHasBlockedLongRunningPattern(argv);
122
+ if (!detail) {
123
+ return null;
124
+ }
125
+ return {
126
+ code: 'long_running_command_pattern',
127
+ detail: `Argv command contains a blocked long-running or background pattern: ${detail}.`,
128
+ };
129
+ }
@@ -1,7 +1,8 @@
1
1
  import { COMMAND_LIFECYCLES, COMMAND_RUN_POLICIES, LONG_RUNNING_LIFECYCLES, isRecord, } from './config-loading.js';
2
2
  import { COMMAND_ENV_POLICIES } from './command-env.js';
3
3
  import { COMMAND_EFFECT_CONCURRENCY, COMMAND_EFFECT_MODES, COMMAND_EFFECT_TYPES, validateCommandEffectLockWarnings, validateCommandEffects, } from './command-effects.js';
4
- import { commandIntentHasBlockedShellBackgroundPattern, commandIntentHasCommandSource, commandIntentNameIsSafe, } from './command-contract-rules.js';
4
+ import { commandIntentBlockedCommandPattern, commandIntentHasBlockedShellBackgroundPattern, commandIntentHasCommandSource, commandIntentNameIsSafe, } from './command-contract-rules.js';
5
+ import { MAX_COMMAND_OUTPUT_BYTES, commandMaxOutputBytesLimitMessage } from './command-output-limits.js';
5
6
  function commandContractIssue(message) {
6
7
  return { message };
7
8
  }
@@ -46,6 +47,12 @@ function validatePositiveIntegerField(table, key, label, issues) {
46
47
  issues.push(commandContractIssue(`${label} must be a positive integer`));
47
48
  }
48
49
  }
50
+ function validateMaxOutputBytesField(table, key, label, issues) {
51
+ validatePositiveIntegerField(table, key, label, issues);
52
+ if (isPositiveInteger(table[key]) && Number(table[key]) > MAX_COMMAND_OUTPUT_BYTES) {
53
+ issues.push(commandContractIssue(commandMaxOutputBytesLimitMessage(label)));
54
+ }
55
+ }
49
56
  function validateAllowedStringField(table, key, label, allowedValues, issues) {
50
57
  if (!hasOwn(table, key)) {
51
58
  return;
@@ -70,7 +77,7 @@ function validateCommandDefaults(commandsToml, issues) {
70
77
  validateAllowedStringField(defaults, 'env_policy', '[commands.defaults].env_policy', COMMAND_ENV_POLICIES, issues);
71
78
  validateStringArrayField(defaults, 'env_allowlist', '[commands.defaults].env_allowlist', issues);
72
79
  validatePositiveIntegerField(defaults, 'default_timeout_seconds', '[commands.defaults].default_timeout_seconds', issues);
73
- validatePositiveIntegerField(defaults, 'max_output_bytes', '[commands.defaults].max_output_bytes', issues);
80
+ validateMaxOutputBytesField(defaults, 'max_output_bytes', '[commands.defaults].max_output_bytes', issues);
74
81
  validatePositiveIntegerField(defaults, 'kill_after_seconds', '[commands.defaults].kill_after_seconds', issues);
75
82
  }
76
83
  function validateCommandResources(commandsToml, issues) {
@@ -148,6 +155,7 @@ function validateCommandIntent(intentName, intent, issues) {
148
155
  validateAllowedStringField(intent, 'run_policy', `[commands.intents.${intentName}].run_policy`, COMMAND_RUN_POLICIES, issues);
149
156
  validateAllowedStringField(intent, 'env_policy', `[commands.intents.${intentName}].env_policy`, COMMAND_ENV_POLICIES, issues);
150
157
  validateStringArrayField(intent, 'env_allowlist', `[commands.intents.${intentName}].env_allowlist`, issues);
158
+ validateMaxOutputBytesField(intent, 'max_output_bytes', `[commands.intents.${intentName}].max_output_bytes`, issues);
151
159
  validateCommandIntentSelection(intentName, intent, issues);
152
160
  if (intent.status !== 'configured') {
153
161
  return;
@@ -178,6 +186,10 @@ function validateCommandIntent(intentName, intent, issues) {
178
186
  if (commandIntentHasBlockedShellBackgroundPattern(intent)) {
179
187
  issues.push(commandContractIssue(`Shell intent ${intentName} contains a blocked long-running or background pattern`));
180
188
  }
189
+ const blockedCommandPattern = commandIntentBlockedCommandPattern(intent);
190
+ if (blockedCommandPattern?.code === 'long_running_command_pattern') {
191
+ issues.push(commandContractIssue(`Intent ${intentName} contains a blocked long-running or background command pattern`));
192
+ }
181
193
  if (hasOwn(intent, 'success_exit_codes')) {
182
194
  const value = intent.success_exit_codes;
183
195
  if (!Array.isArray(value) || value.length === 0 || value.some((entry) => !Number.isInteger(entry))) {
@@ -1,5 +1,5 @@
1
1
  import { isRecord, readString } from './config-loading.js';
2
- import { commandIntentHasBlockedShellBackgroundPattern, commandIntentHasCommandSource, commandIntentNameIsSafe, } from './command-contract-rules.js';
2
+ import { commandIntentBlockedCommandPattern, commandIntentHasBlockedShellBackgroundPattern, commandIntentHasCommandSource, commandIntentNameIsSafe, } from './command-contract-rules.js';
3
3
  export function evaluateCommandIntentEligibility(intentName, rawIntent) {
4
4
  if (!commandIntentNameIsSafe(intentName)) {
5
5
  return {
@@ -68,6 +68,14 @@ export function evaluateCommandIntentEligibility(intentName, rawIntent) {
68
68
  detail: 'Shell command contains a blocked long-running or background pattern.',
69
69
  };
70
70
  }
71
+ const blockedPattern = commandIntentBlockedCommandPattern(rawIntent);
72
+ if (blockedPattern?.code === 'long_running_command_pattern') {
73
+ return {
74
+ ok: false,
75
+ code: 'blocked_long_running_command_pattern',
76
+ detail: blockedPattern.detail,
77
+ };
78
+ }
71
79
  return {
72
80
  ok: true,
73
81
  code: 'ok',
@@ -0,0 +1,5 @@
1
+ export const DEFAULT_COMMAND_MAX_OUTPUT_BYTES = 1_048_576;
2
+ export const MAX_COMMAND_OUTPUT_BYTES = 16 * 1024 * 1024;
3
+ export function commandMaxOutputBytesLimitMessage(label) {
4
+ return `${label} must be less than or equal to ${MAX_COMMAND_OUTPUT_BYTES}`;
5
+ }
@@ -2,7 +2,8 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { COMMAND_LIFECYCLES, COMMAND_RUN_POLICIES, LONG_RUNNING_LIFECYCLES, isRecord, readPositiveInteger, readString, readStringArray, } from './config-loading.js';
4
4
  import { evaluateCommandIntentEligibility, } from './command-intent-eligibility.js';
5
- import { commandIntentHasBlockedShellBackgroundPattern, commandIntentHasCommandSource, commandIntentNameIsSafe, } from './command-contract-rules.js';
5
+ import { commandIntentBlockedCommandPattern, commandIntentHasBlockedShellBackgroundPattern, commandIntentHasCommandSource, commandIntentNameIsSafe, } from './command-contract-rules.js';
6
+ import { MAX_COMMAND_OUTPUT_BYTES } from './command-output-limits.js';
6
7
  import { commandEffectsConflict, normalizeCommandEffects } from './command-effects.js';
7
8
  import { listChangeClassificationValidationReasons } from './change-classification.js';
8
9
  import { parseSkillIndexRoutes } from './skill-route-alignment.js';
@@ -309,6 +310,10 @@ function lintIntent(name, value, issues) {
309
310
  if (lifecycle === 'oneshot' && readPositiveInteger(value, 'timeout_seconds') === undefined) {
310
311
  pushIssue(issues, 'error', 'oneshot_missing_timeout', name, `Oneshot intent ${name} must define timeout_seconds.`);
311
312
  }
313
+ const maxOutputBytes = readPositiveInteger(value, 'max_output_bytes');
314
+ if (maxOutputBytes !== undefined && maxOutputBytes > MAX_COMMAND_OUTPUT_BYTES) {
315
+ pushIssue(issues, 'error', 'max_output_bytes_exceeds_limit', name, `Intent ${name} max_output_bytes must be less than or equal to ${MAX_COMMAND_OUTPUT_BYTES}.`);
316
+ }
312
317
  if (lifecycle === 'oneshot' && readString(value, 'stdin') !== 'closed') {
313
318
  pushIssue(issues, 'error', 'oneshot_stdin_not_closed', name, `Oneshot intent ${name} must set stdin to closed.`);
314
319
  }
@@ -321,6 +326,10 @@ function lintIntent(name, value, issues) {
321
326
  if (commandIntentHasBlockedShellBackgroundPattern(value)) {
322
327
  pushIssue(issues, 'error', 'shell_background_pattern', name, `Shell intent ${name} contains a blocked long-running or background pattern.`);
323
328
  }
329
+ const blockedCommandPattern = commandIntentBlockedCommandPattern(value);
330
+ if (blockedCommandPattern?.code === 'long_running_command_pattern') {
331
+ pushIssue(issues, 'error', 'long_running_command_pattern', name, `Intent ${name} contains a blocked long-running or background command pattern.`);
332
+ }
324
333
  if (!successExitCodesAreValid(value)) {
325
334
  pushIssue(issues, 'error', 'invalid_success_exit_codes', name, `Intent ${name} success_exit_codes must be an integer array.`);
326
335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mustflow",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Agent workflow documents and CLI for mustflow repository roots.",
5
5
  "type": "module",
6
6
  "license": "MIT-0",
package/schemas/README.md CHANGED
@@ -35,10 +35,10 @@ Current schemas:
35
35
  `mf explain verify --reason <event> --json`, `mf explain retention --json`, `mf explain skills --json`,
36
36
  and `mf explain surface --json`. Verify explanations include the shared `decisionGraph` evidence model.
37
37
  - `verify-report.schema.json`: output of `mf verify --reason <event> --json`, including an
38
- evidence-based completion verdict and evidence model with a conservative coverage matrix for the
39
- selected receipts and skipped checks
38
+ explicit execution aggregate, evidence-based completion verdict, and evidence model with a
39
+ conservative coverage matrix for the selected receipts and skipped checks
40
40
  - `verify-run-manifest.schema.json`: `.mustflow/state/runs/verify-latest/manifest.json`, including
41
- the same completion verdict, evidence model, and coverage matrix as the verify report
41
+ the same execution aggregate, completion verdict, evidence model, and coverage matrix as the verify report
42
42
  - `change-verification-report.schema.json`: output of `mf verify --reason <event> --plan-only --json` and
43
43
  `mf verify --from-classification <classify-report.json> --plan-only --json`, including the `decision_graph` that links
44
44
  changed surfaces, classification reasons, command candidates, eligibility, selected or not-selected state,
@@ -251,7 +251,8 @@
251
251
  "missing_timeout",
252
252
  "missing_command_source",
253
253
  "unsafe_intent_name",
254
- "blocked_shell_background_pattern"
254
+ "blocked_shell_background_pattern",
255
+ "blocked_long_running_command_pattern"
255
256
  ]
256
257
  },
257
258
  "verificationCandidate": {
@@ -139,7 +139,8 @@
139
139
  "missing_timeout",
140
140
  "missing_command_source",
141
141
  "unsafe_intent_name",
142
- "blocked_shell_background_pattern"
142
+ "blocked_shell_background_pattern",
143
+ "blocked_long_running_command_pattern"
143
144
  ]
144
145
  },
145
146
  "runnable": { "type": "boolean" },
@@ -829,6 +829,7 @@
829
829
  "missing_command_source",
830
830
  "unsafe_intent_name",
831
831
  "blocked_shell_background_pattern",
832
+ "blocked_long_running_command_pattern",
832
833
  null
833
834
  ]
834
835
  },
@@ -33,6 +33,7 @@
33
33
  "type": "string",
34
34
  "pattern": "^sha256:[0-9a-f]{64}$"
35
35
  },
36
+ "execution_status": { "enum": ["passed", "partial", "failed", "blocked"] },
36
37
  "status": { "enum": ["passed", "partial", "failed", "blocked"] },
37
38
  "completion_verdict": { "$ref": "#/$defs/completionVerdict" },
38
39
  "evidence_model": { "$ref": "#/$defs/evidenceModel" },
@@ -34,6 +34,7 @@
34
34
  "type": "string",
35
35
  "pattern": "^sha256:[0-9a-f]{64}$"
36
36
  },
37
+ "execution_status": { "enum": ["passed", "partial", "failed", "blocked"] },
37
38
  "status": { "enum": ["passed", "partial", "failed", "blocked"] },
38
39
  "completion_verdict": { "$ref": "#/$defs/completionVerdict" },
39
40
  "evidence_model": { "$ref": "#/$defs/evidenceModel" },