mustflow 2.17.0 → 2.18.2

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 (42) hide show
  1. package/README.md +3 -3
  2. package/dist/cli/commands/classify.js +13 -3
  3. package/dist/cli/commands/dashboard.js +2 -1
  4. package/dist/cli/commands/explain-verify.js +2 -2
  5. package/dist/cli/commands/impact.js +13 -3
  6. package/dist/cli/commands/run.js +156 -104
  7. package/dist/cli/commands/verify.js +157 -45
  8. package/dist/cli/i18n/en.js +10 -1
  9. package/dist/cli/i18n/es.js +10 -1
  10. package/dist/cli/i18n/fr.js +10 -1
  11. package/dist/cli/i18n/hi.js +10 -1
  12. package/dist/cli/i18n/ko.js +10 -1
  13. package/dist/cli/i18n/zh.js +10 -1
  14. package/dist/cli/lib/git-changes.js +25 -2
  15. package/dist/cli/lib/local-index/constants.js +4 -1
  16. package/dist/cli/lib/local-index/index.js +22 -5
  17. package/dist/cli/lib/repo-map.js +90 -30
  18. package/dist/cli/lib/run-plan.js +25 -2
  19. package/dist/cli/lib/validation/index.js +2 -1
  20. package/dist/core/atomic-state-write.js +31 -0
  21. package/dist/core/bounded-output.js +23 -1
  22. package/dist/core/check-issues.js +3 -0
  23. package/dist/core/command-contract-rules.js +104 -2
  24. package/dist/core/command-contract-validation.js +71 -9
  25. package/dist/core/command-intent-eligibility.js +9 -1
  26. package/dist/core/command-output-limits.js +5 -0
  27. package/dist/core/completion-verdict.js +2 -1
  28. package/dist/core/contract-lint.js +10 -1
  29. package/dist/core/public-json-contracts.js +1 -1
  30. package/dist/core/run-receipt.js +20 -13
  31. package/dist/core/source-anchors.js +96 -24
  32. package/dist/core/verification-evidence.js +4 -1
  33. package/package.json +1 -1
  34. package/schemas/README.md +4 -4
  35. package/schemas/change-verification-report.schema.json +2 -1
  36. package/schemas/contract-lint-report.schema.json +2 -1
  37. package/schemas/explain-report.schema.json +1 -0
  38. package/schemas/latest-run-pointer.schema.json +1 -0
  39. package/schemas/run-receipt.schema.json +26 -3
  40. package/schemas/verify-report.schema.json +2 -1
  41. package/schemas/verify-run-manifest.schema.json +2 -1
  42. package/templates/default/manifest.toml +1 -1
@@ -4,14 +4,17 @@ 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],
14
16
  ['mustflow.command_contract.shared_writes_without_effects', /^Strict warning: configured agent-runnable intents .+ share path:.+ through writes without explicit effects or resource locks$/u],
17
+ ['mustflow.command_contract.broad_env_inheritance', /^Strict warning: configured agent-runnable intent [^\s]+ (?:implicitly inherits the host environment|uses env_policy = "inherit")/u],
15
18
  ['mustflow.prompt_cache.required', /^Strict: \[prompt_cache\] table is required$/u],
16
19
  ['mustflow.prompt_cache.volatile_in_stable', /^Strict: \[prompt_cache\.layers\.stable\]\.read must not include volatile path /u],
17
20
  ['mustflow.refresh.hash_method_required', /^Strict: \[refresh\]\.default_method should be "hash_check" for cache-friendly refresh$/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,10 +1,14 @@
1
1
  import { COMMAND_LIFECYCLES, COMMAND_RUN_POLICIES, LONG_RUNNING_LIFECYCLES, isRecord, } from './config-loading.js';
2
- import { COMMAND_ENV_POLICIES } from './command-env.js';
2
+ import { COMMAND_ENV_POLICIES, DEFAULT_COMMAND_ENV_POLICY } 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
  }
9
+ function commandContractWarning(message) {
10
+ return { message, severity: 'warning' };
11
+ }
8
12
  function hasOwn(table, key) {
9
13
  return Object.prototype.hasOwnProperty.call(table, key);
10
14
  }
@@ -46,6 +50,12 @@ function validatePositiveIntegerField(table, key, label, issues) {
46
50
  issues.push(commandContractIssue(`${label} must be a positive integer`));
47
51
  }
48
52
  }
53
+ function validateMaxOutputBytesField(table, key, label, issues) {
54
+ validatePositiveIntegerField(table, key, label, issues);
55
+ if (isPositiveInteger(table[key]) && Number(table[key]) > MAX_COMMAND_OUTPUT_BYTES) {
56
+ issues.push(commandContractIssue(commandMaxOutputBytesLimitMessage(label)));
57
+ }
58
+ }
49
59
  function validateAllowedStringField(table, key, label, allowedValues, issues) {
50
60
  if (!hasOwn(table, key)) {
51
61
  return;
@@ -70,7 +80,7 @@ function validateCommandDefaults(commandsToml, issues) {
70
80
  validateAllowedStringField(defaults, 'env_policy', '[commands.defaults].env_policy', COMMAND_ENV_POLICIES, issues);
71
81
  validateStringArrayField(defaults, 'env_allowlist', '[commands.defaults].env_allowlist', issues);
72
82
  validatePositiveIntegerField(defaults, 'default_timeout_seconds', '[commands.defaults].default_timeout_seconds', issues);
73
- validatePositiveIntegerField(defaults, 'max_output_bytes', '[commands.defaults].max_output_bytes', issues);
83
+ validateMaxOutputBytesField(defaults, 'max_output_bytes', '[commands.defaults].max_output_bytes', issues);
74
84
  validatePositiveIntegerField(defaults, 'kill_after_seconds', '[commands.defaults].kill_after_seconds', issues);
75
85
  }
76
86
  function validateCommandResources(commandsToml, issues) {
@@ -148,6 +158,7 @@ function validateCommandIntent(intentName, intent, issues) {
148
158
  validateAllowedStringField(intent, 'run_policy', `[commands.intents.${intentName}].run_policy`, COMMAND_RUN_POLICIES, issues);
149
159
  validateAllowedStringField(intent, 'env_policy', `[commands.intents.${intentName}].env_policy`, COMMAND_ENV_POLICIES, issues);
150
160
  validateStringArrayField(intent, 'env_allowlist', `[commands.intents.${intentName}].env_allowlist`, issues);
161
+ validateMaxOutputBytesField(intent, 'max_output_bytes', `[commands.intents.${intentName}].max_output_bytes`, issues);
151
162
  validateCommandIntentSelection(intentName, intent, issues);
152
163
  if (intent.status !== 'configured') {
153
164
  return;
@@ -178,6 +189,10 @@ function validateCommandIntent(intentName, intent, issues) {
178
189
  if (commandIntentHasBlockedShellBackgroundPattern(intent)) {
179
190
  issues.push(commandContractIssue(`Shell intent ${intentName} contains a blocked long-running or background pattern`));
180
191
  }
192
+ const blockedCommandPattern = commandIntentBlockedCommandPattern(intent);
193
+ if (blockedCommandPattern?.code === 'long_running_command_pattern') {
194
+ issues.push(commandContractIssue(`Intent ${intentName} contains a blocked long-running or background command pattern`));
195
+ }
181
196
  if (hasOwn(intent, 'success_exit_codes')) {
182
197
  const value = intent.success_exit_codes;
183
198
  if (!Array.isArray(value) || value.length === 0 || value.some((entry) => !Number.isInteger(entry))) {
@@ -186,6 +201,50 @@ function validateCommandIntent(intentName, intent, issues) {
186
201
  }
187
202
  validateCommandIntentEffects(intentName, intent, issues);
188
203
  }
204
+ function readValidCommandEnvPolicy(table) {
205
+ if (!table || !hasOwn(table, 'env_policy')) {
206
+ return undefined;
207
+ }
208
+ const value = table.env_policy;
209
+ return typeof value === 'string' && COMMAND_ENV_POLICIES.has(value)
210
+ ? value
211
+ : undefined;
212
+ }
213
+ function getEffectiveCommandEnvPolicy(defaults, intent) {
214
+ const intentPolicy = readValidCommandEnvPolicy(intent);
215
+ if (intentPolicy) {
216
+ return { policy: intentPolicy, source: 'intent' };
217
+ }
218
+ const defaultPolicy = readValidCommandEnvPolicy(defaults);
219
+ if (defaultPolicy) {
220
+ return { policy: defaultPolicy, source: 'defaults' };
221
+ }
222
+ return { policy: DEFAULT_COMMAND_ENV_POLICY, source: 'implicit' };
223
+ }
224
+ function validateCommandEnvInheritanceWarnings(commandsToml) {
225
+ const issues = [];
226
+ if (!commandsToml || !isRecord(commandsToml.intents)) {
227
+ return issues;
228
+ }
229
+ const defaults = isRecord(commandsToml.defaults) ? commandsToml.defaults : undefined;
230
+ for (const [intentName, intent] of Object.entries(commandsToml.intents)) {
231
+ if (!isRecord(intent) || intent.status !== 'configured' || intent.run_policy !== 'agent_allowed') {
232
+ continue;
233
+ }
234
+ const envPolicy = getEffectiveCommandEnvPolicy(defaults, intent);
235
+ if (envPolicy.policy !== 'inherit') {
236
+ continue;
237
+ }
238
+ const networkScope = intent.network === true ? ' with network = true' : '';
239
+ const migration = 'set env_policy = "minimal" or "allowlist" unless broad host state is required';
240
+ if (envPolicy.source === 'implicit') {
241
+ issues.push(commandContractWarning(`configured agent-runnable intent ${intentName} implicitly inherits the host environment${networkScope}; ${migration}`));
242
+ continue;
243
+ }
244
+ issues.push(commandContractWarning(`configured agent-runnable intent ${intentName} uses env_policy = "inherit"${networkScope}; ${migration}`));
245
+ }
246
+ return issues;
247
+ }
189
248
  /**
190
249
  * mf:anchor core.command-contract-validation
191
250
  * purpose: Validate command intent declarations that gate agent-executable repository commands.
@@ -216,15 +275,18 @@ export function validateCommandContractConfig(commandsToml) {
216
275
  }
217
276
  export function validateCommandContractStrictDefaults(projectRoot, commandsToml) {
218
277
  const issues = [];
219
- if (!commandsToml || !isRecord(commandsToml.defaults)) {
278
+ if (!commandsToml) {
220
279
  return issues;
221
280
  }
222
- if (!hasOwn(commandsToml.defaults, 'max_output_bytes')) {
223
- issues.push(commandContractIssue('[commands.defaults].max_output_bytes is required'));
224
- }
225
- if (!hasOwn(commandsToml.defaults, 'on_timeout')) {
226
- issues.push(commandContractIssue('[commands.defaults].on_timeout is required'));
281
+ if (isRecord(commandsToml.defaults)) {
282
+ if (!hasOwn(commandsToml.defaults, 'max_output_bytes')) {
283
+ issues.push(commandContractIssue('[commands.defaults].max_output_bytes is required'));
284
+ }
285
+ if (!hasOwn(commandsToml.defaults, 'on_timeout')) {
286
+ issues.push(commandContractIssue('[commands.defaults].on_timeout is required'));
287
+ }
227
288
  }
289
+ issues.push(...validateCommandEnvInheritanceWarnings(commandsToml));
228
290
  issues.push(...validateCommandEffects(projectRoot, commandsToml));
229
291
  issues.push(...validateCommandEffectLockWarnings(commandsToml));
230
292
  return issues;
@@ -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
+ }
@@ -244,7 +244,8 @@ export function createDashboardCompletionVerdict(input) {
244
244
  const receiptBinding = input.receiptBinding ?? emptyReceiptBindingEvidence();
245
245
  const latestRunFailed = input.latestRunStatus === 'failed' ||
246
246
  input.latestRunStatus === 'timed_out' ||
247
- input.latestRunStatus === 'start_failed';
247
+ input.latestRunStatus === 'start_failed' ||
248
+ input.latestRunStatus === 'output_limit_exceeded';
248
249
  let status = 'unverified';
249
250
  let primaryReason = 'dashboard_does_not_execute_verification';
250
251
  const blockers = [];
@@ -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
  }
@@ -135,7 +135,7 @@ const PUBLIC_JSON_SCHEMA_CONTRACTS = [
135
135
  {
136
136
  id: 'verify-run-manifest',
137
137
  schemaFile: 'verify-run-manifest.schema.json',
138
- producer: '.mustflow/state/runs/verify-latest/manifest.json',
138
+ producer: '.mustflow/state/runs/verify-*/manifest.json',
139
139
  packaged: true,
140
140
  documented: true,
141
141
  },
@@ -1,6 +1,7 @@
1
- import { mkdirSync, writeFileSync } from 'node:fs';
2
1
  import { createHash } from 'node:crypto';
3
2
  import path from 'node:path';
3
+ import { atomicWriteJsonFile, createStateRunId } from './atomic-state-write.js';
4
+ import { decodeUtf8Tail } from './bounded-output.js';
4
5
  import { DEFAULT_RUN_RECEIPT_TAIL_BYTES } from './retention-policy.js';
5
6
  import { redactSecretLikeText } from './secret-redaction.js';
6
7
  const RUN_RECEIPT_SCHEMA_VERSION = '1';
@@ -11,13 +12,7 @@ function toPosixPath(value) {
11
12
  }
12
13
  function truncateTextByBytes(text, maxBytes) {
13
14
  const buffer = Buffer.from(text, 'utf8');
14
- if (buffer.byteLength <= maxBytes) {
15
- return { text, truncated: false };
16
- }
17
- return {
18
- text: buffer.subarray(buffer.byteLength - maxBytes).toString('utf8'),
19
- truncated: true,
20
- };
15
+ return decodeUtf8Tail(buffer, maxBytes);
21
16
  }
22
17
  function recordRedaction(state, field, result) {
23
18
  if (!result.redacted) {
@@ -62,8 +57,11 @@ function summarizeOutput(output, maxOutputBytes, tailBytes, field, state) {
62
57
  redaction_kinds: redaction.redactionKinds,
63
58
  };
64
59
  }
65
- function getReceiptRelativePath() {
66
- return toPosixPath(path.join(RUN_RECEIPT_DIR, LATEST_RUN_RECEIPT));
60
+ export function createRunReceiptRelativePath() {
61
+ return toPosixPath(path.join(RUN_RECEIPT_DIR, createStateRunId('run'), 'receipt.json'));
62
+ }
63
+ function getReceiptRelativePath(receiptPath) {
64
+ return receiptPath ?? toPosixPath(path.join(RUN_RECEIPT_DIR, LATEST_RUN_RECEIPT));
67
65
  }
68
66
  function stableJson(value) {
69
67
  if (Array.isArray(value)) {
@@ -100,6 +98,9 @@ function getErrorKind(status, exitCode) {
100
98
  if (status === 'start_failed') {
101
99
  return 'start_failed';
102
100
  }
101
+ if (status === 'output_limit_exceeded') {
102
+ return 'output_limit_exceeded';
103
+ }
103
104
  if (status === 'failed' && exitCode !== null) {
104
105
  return 'exit_code';
105
106
  }
@@ -240,6 +241,7 @@ export function createRunReceipt(input) {
240
241
  signal: input.signal,
241
242
  error,
242
243
  kill_method: input.killMethod,
244
+ ...(input.termination ? { termination: input.termination } : {}),
243
245
  stdout,
244
246
  stderr,
245
247
  write_drift: input.writeDrift,
@@ -272,12 +274,17 @@ export function createRunReceipt(input) {
272
274
  redaction_kinds: [...redactionState.kinds].sort(),
273
275
  fields: [...redactionState.fields].sort(),
274
276
  },
275
- receipt_path: getReceiptRelativePath(),
277
+ receipt_path: getReceiptRelativePath(input.receiptPath),
276
278
  };
277
279
  }
278
280
  export function writeRunReceipt(projectRoot, receipt) {
279
281
  const receiptDir = path.join(projectRoot, RUN_RECEIPT_DIR);
280
282
  const latestPath = path.join(receiptDir, LATEST_RUN_RECEIPT);
281
- mkdirSync(receiptDir, { recursive: true });
282
- writeFileSync(latestPath, `${JSON.stringify(receipt, null, 2)}\n`);
283
+ const receiptPath = path.resolve(projectRoot, receipt.receipt_path);
284
+ const relativeToRunDir = path.relative(receiptDir, receiptPath);
285
+ if (relativeToRunDir.startsWith('..') || path.isAbsolute(relativeToRunDir)) {
286
+ throw new Error(`Run receipt path must stay inside ${RUN_RECEIPT_DIR}`);
287
+ }
288
+ atomicWriteJsonFile(receiptPath, receipt);
289
+ atomicWriteJsonFile(latestPath, receipt);
283
290
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { SECRET_LIKE_PATTERNS, textContainsSecretLike } from './secret-redaction.js';
4
4
  export const SOURCE_ANCHOR_EXTENSIONS = new Set(['.cjs', '.go', '.js', '.jsx', '.mjs', '.py', '.rs', '.ts', '.tsx']);
@@ -49,23 +49,94 @@ export const SOURCE_ANCHOR_SECRET_LIKE_PATTERNS = SECRET_LIKE_PATTERNS;
49
49
  function toPosixPath(value) {
50
50
  return value.split(path.sep).join('/');
51
51
  }
52
- function listFilesRecursive(root, ignoredDirectoryNames, current = root) {
52
+ function pathIsInsideRoot(rootRealPath, candidateRealPath) {
53
+ const relative = path.relative(rootRealPath, candidateRealPath);
54
+ return relative.length === 0 || (!relative.startsWith('..') && !path.isAbsolute(relative));
55
+ }
56
+ function shouldIncludeSourceAnchorFile(relativePath, options) {
57
+ if (!options.allowedExtensions.has(path.posix.extname(relativePath))) {
58
+ return false;
59
+ }
60
+ if (options.excludeGeneratedOrVendor && sourceAnchorPathIsGeneratedOrVendor(relativePath)) {
61
+ return false;
62
+ }
63
+ if (options.include.length > 0 && !matchesAnyGlob(relativePath, options.include)) {
64
+ return false;
65
+ }
66
+ if (options.exclude.length > 0 && matchesAnyGlob(relativePath, options.exclude)) {
67
+ return false;
68
+ }
69
+ return true;
70
+ }
71
+ function fileIsWithinSizeLimit(filePath, maxFileBytes) {
72
+ if (typeof maxFileBytes !== 'number' || !Number.isFinite(maxFileBytes) || maxFileBytes <= 0) {
73
+ return true;
74
+ }
75
+ try {
76
+ return statSync(filePath).size <= maxFileBytes;
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ function listFilesRecursive(root, options, current = root) {
53
83
  if (!existsSync(current)) {
54
84
  return [];
55
85
  }
86
+ const currentRealPath = realpathSync(current);
87
+ if (!pathIsInsideRoot(options.rootRealPath, currentRealPath) || options.visitedRealDirectories.has(currentRealPath)) {
88
+ return [];
89
+ }
90
+ options.visitedRealDirectories.add(currentRealPath);
56
91
  const files = [];
57
- for (const entry of readdirSync(current)) {
58
- const entryPath = path.join(current, entry);
59
- const stat = statSync(entryPath);
60
- if (stat.isDirectory()) {
61
- if (ignoredDirectoryNames.has(entry)) {
92
+ const entries = readdirSync(current, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
93
+ for (const entry of entries) {
94
+ const entryPath = path.join(current, entry.name);
95
+ if (options.ignoredDirectoryNames.has(entry.name)) {
96
+ continue;
97
+ }
98
+ if (entry.isDirectory()) {
99
+ files.push(...listFilesRecursive(root, options, entryPath));
100
+ continue;
101
+ }
102
+ if (entry.isSymbolicLink()) {
103
+ if (!options.followSymlinks) {
62
104
  continue;
63
105
  }
64
- files.push(...listFilesRecursive(root, ignoredDirectoryNames, entryPath));
106
+ let realPath;
107
+ try {
108
+ realPath = realpathSync(entryPath);
109
+ }
110
+ catch {
111
+ continue;
112
+ }
113
+ if (!pathIsInsideRoot(options.rootRealPath, realPath)) {
114
+ continue;
115
+ }
116
+ let stat;
117
+ try {
118
+ stat = statSync(entryPath);
119
+ }
120
+ catch {
121
+ continue;
122
+ }
123
+ if (stat.isDirectory()) {
124
+ files.push(...listFilesRecursive(root, options, entryPath));
125
+ continue;
126
+ }
127
+ if (stat.isFile()) {
128
+ const relativePath = toPosixPath(path.relative(root, entryPath));
129
+ if (shouldIncludeSourceAnchorFile(relativePath, options) && fileIsWithinSizeLimit(entryPath, options.maxFileBytes)) {
130
+ files.push(relativePath);
131
+ }
132
+ }
65
133
  continue;
66
134
  }
67
- if (stat.isFile()) {
68
- files.push(path.relative(root, entryPath));
135
+ if (entry.isFile()) {
136
+ const relativePath = toPosixPath(path.relative(root, entryPath));
137
+ if (shouldIncludeSourceAnchorFile(relativePath, options) && fileIsWithinSizeLimit(entryPath, options.maxFileBytes)) {
138
+ files.push(relativePath);
139
+ }
69
140
  }
70
141
  }
71
142
  return files.sort((left, right) => left.localeCompare(right));
@@ -127,25 +198,26 @@ function normalizeAllowedExtensions(allowedExtensions) {
127
198
  function mergeIgnoredDirectoryNames(ignoredDirectoryNames) {
128
199
  return new Set([...(ignoredDirectoryNames ?? []), ...SOURCE_ANCHOR_DEFAULT_EXCLUDED_PATH_PARTS]);
129
200
  }
130
- function fileIsWithinSizeLimit(root, relativePath, maxFileBytes) {
131
- if (typeof maxFileBytes !== 'number' || !Number.isFinite(maxFileBytes) || maxFileBytes <= 0) {
132
- return true;
133
- }
134
- const filePath = path.join(root, ...relativePath.split('/'));
135
- return statSync(filePath).size <= maxFileBytes;
136
- }
137
201
  export function listSourceAnchorFiles(root, options = {}) {
202
+ if (!existsSync(root)) {
203
+ return [];
204
+ }
138
205
  const ignoredDirectoryNames = mergeIgnoredDirectoryNames(options.ignoredDirectoryNames);
139
206
  const allowedExtensions = normalizeAllowedExtensions(options.allowedExtensions);
140
207
  const include = (options.include ?? []).map((pattern) => globToRegExp(pattern));
141
208
  const exclude = (options.exclude ?? []).map((pattern) => globToRegExp(pattern));
142
- return listFilesRecursive(root, ignoredDirectoryNames)
143
- .map((relativePath) => toPosixPath(relativePath))
144
- .filter((relativePath) => allowedExtensions.has(path.posix.extname(relativePath)))
145
- .filter((relativePath) => options.excludeGeneratedOrVendor !== true || !sourceAnchorPathIsGeneratedOrVendor(relativePath))
146
- .filter((relativePath) => include.length === 0 || matchesAnyGlob(relativePath, include))
147
- .filter((relativePath) => exclude.length === 0 || !matchesAnyGlob(relativePath, exclude))
148
- .filter((relativePath) => fileIsWithinSizeLimit(root, relativePath, options.maxFileBytes));
209
+ const rootRealPath = realpathSync(root);
210
+ return listFilesRecursive(root, {
211
+ ignoredDirectoryNames,
212
+ allowedExtensions,
213
+ include,
214
+ exclude,
215
+ excludeGeneratedOrVendor: options.excludeGeneratedOrVendor === true,
216
+ maxFileBytes: options.maxFileBytes,
217
+ followSymlinks: options.followSymlinks === true,
218
+ rootRealPath,
219
+ visitedRealDirectories: new Set(),
220
+ });
149
221
  }
150
222
  export function stripSourceAnchorCommentPrefix(line) {
151
223
  return line
@@ -28,7 +28,10 @@ function resultForIntent(results, intent) {
28
28
  function requirementOutcome(input) {
29
29
  if (input.selectedIntents.some((intent) => {
30
30
  const result = resultForIntent(input.results, intent);
31
- return result?.status === 'failed' || result?.status === 'timed_out' || result?.status === 'start_failed';
31
+ return (result?.status === 'failed' ||
32
+ result?.status === 'timed_out' ||
33
+ result?.status === 'start_failed' ||
34
+ result?.status === 'output_limit_exceeded');
32
35
  })) {
33
36
  return 'contradicted';
34
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mustflow",
3
- "version": "2.17.0",
3
+ "version": "2.18.2",
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
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
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
+ - `verify-run-manifest.schema.json`: `.mustflow/state/runs/verify-*/manifest.json`, including
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" },