mustflow 2.18.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.
@@ -1,11 +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
4
  import { commandIntentBlockedCommandPattern, commandIntentHasBlockedShellBackgroundPattern, commandIntentHasCommandSource, commandIntentNameIsSafe, } from './command-contract-rules.js';
5
5
  import { MAX_COMMAND_OUTPUT_BYTES, commandMaxOutputBytesLimitMessage } from './command-output-limits.js';
6
6
  function commandContractIssue(message) {
7
7
  return { message };
8
8
  }
9
+ function commandContractWarning(message) {
10
+ return { message, severity: 'warning' };
11
+ }
9
12
  function hasOwn(table, key) {
10
13
  return Object.prototype.hasOwnProperty.call(table, key);
11
14
  }
@@ -198,6 +201,50 @@ function validateCommandIntent(intentName, intent, issues) {
198
201
  }
199
202
  validateCommandIntentEffects(intentName, intent, issues);
200
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
+ }
201
248
  /**
202
249
  * mf:anchor core.command-contract-validation
203
250
  * purpose: Validate command intent declarations that gate agent-executable repository commands.
@@ -228,15 +275,18 @@ export function validateCommandContractConfig(commandsToml) {
228
275
  }
229
276
  export function validateCommandContractStrictDefaults(projectRoot, commandsToml) {
230
277
  const issues = [];
231
- if (!commandsToml || !isRecord(commandsToml.defaults)) {
278
+ if (!commandsToml) {
232
279
  return issues;
233
280
  }
234
- if (!hasOwn(commandsToml.defaults, 'max_output_bytes')) {
235
- issues.push(commandContractIssue('[commands.defaults].max_output_bytes is required'));
236
- }
237
- if (!hasOwn(commandsToml.defaults, 'on_timeout')) {
238
- 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
+ }
239
288
  }
289
+ issues.push(...validateCommandEnvInheritanceWarnings(commandsToml));
240
290
  issues.push(...validateCommandEffects(projectRoot, commandsToml));
241
291
  issues.push(...validateCommandEffectLockWarnings(commandsToml));
242
292
  return issues;
@@ -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 = [];
@@ -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.18.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
@@ -37,7 +37,7 @@ Current schemas:
37
37
  - `verify-report.schema.json`: output of `mf verify --reason <event> --json`, including an
38
38
  explicit execution aggregate, evidence-based completion verdict, and evidence model with a
39
39
  conservative coverage matrix for the selected receipts and skipped checks
40
- - `verify-run-manifest.schema.json`: `.mustflow/state/runs/verify-latest/manifest.json`, including
40
+ - `verify-run-manifest.schema.json`: `.mustflow/state/runs/verify-*/manifest.json`, including
41
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
@@ -35,7 +35,7 @@
35
35
  "schema_version": { "const": "1" },
36
36
  "command": { "const": "run" },
37
37
  "intent": { "type": "string" },
38
- "status": { "enum": ["passed", "failed", "timed_out", "start_failed"] },
38
+ "status": { "enum": ["passed", "failed", "timed_out", "start_failed", "output_limit_exceeded"] },
39
39
  "timed_out": { "type": "boolean" },
40
40
  "started_at": { "type": "string", "format": "date-time" },
41
41
  "finished_at": { "type": "string", "format": "date-time" },
@@ -64,6 +64,7 @@
64
64
  "signal": { "type": ["string", "null"] },
65
65
  "error": { "type": ["string", "null"] },
66
66
  "kill_method": { "type": ["string", "null"] },
67
+ "termination": { "$ref": "#/$defs/termination" },
67
68
  "stdout": { "$ref": "#/$defs/output" },
68
69
  "stderr": { "$ref": "#/$defs/output" },
69
70
  "write_drift": { "$ref": "#/$defs/writeDrift" },
@@ -182,10 +183,10 @@
182
183
  "additionalProperties": false,
183
184
  "required": ["status", "exit_code_class", "timed_out", "error_kind"],
184
185
  "properties": {
185
- "status": { "enum": ["passed", "failed", "timed_out", "start_failed"] },
186
+ "status": { "enum": ["passed", "failed", "timed_out", "start_failed", "output_limit_exceeded"] },
186
187
  "exit_code_class": { "enum": ["success", "failure", "no_exit_code"] },
187
188
  "timed_out": { "type": "boolean" },
188
- "error_kind": { "enum": ["timeout", "start_failed", "exit_code", null] }
189
+ "error_kind": { "enum": ["timeout", "start_failed", "output_limit_exceeded", "exit_code", null] }
189
190
  }
190
191
  },
191
192
  "quality": {
@@ -200,6 +201,28 @@
200
201
  }
201
202
  }
202
203
  },
204
+ "termination": {
205
+ "type": "object",
206
+ "additionalProperties": false,
207
+ "required": [
208
+ "reason",
209
+ "method",
210
+ "graceful_signal",
211
+ "forced_signal",
212
+ "forced_kill_attempted",
213
+ "confirmed",
214
+ "cleanup_pending"
215
+ ],
216
+ "properties": {
217
+ "reason": { "const": "timeout" },
218
+ "method": { "type": "string" },
219
+ "graceful_signal": { "type": ["string", "null"] },
220
+ "forced_signal": { "type": ["string", "null"] },
221
+ "forced_kill_attempted": { "type": "boolean" },
222
+ "confirmed": { "type": "boolean" },
223
+ "cleanup_pending": { "type": "boolean" }
224
+ }
225
+ },
203
226
  "redaction": {
204
227
  "type": "object",
205
228
  "additionalProperties": false,
@@ -88,7 +88,7 @@
88
88
  ],
89
89
  "properties": {
90
90
  "intent": { "type": ["string", "null"] },
91
- "status": { "enum": ["passed", "failed", "timed_out", "start_failed", "skipped"] },
91
+ "status": { "enum": ["passed", "failed", "timed_out", "start_failed", "output_limit_exceeded", "skipped"] },
92
92
  "skipped": { "type": "boolean" },
93
93
  "reason": { "type": ["string", "null"] },
94
94
  "detail": { "type": ["string", "null"] },
@@ -265,7 +265,7 @@
265
265
  ],
266
266
  "properties": {
267
267
  "intent": { "type": ["string", "null"] },
268
- "status": { "enum": ["passed", "failed", "timed_out", "start_failed", "skipped"] },
268
+ "status": { "enum": ["passed", "failed", "timed_out", "start_failed", "output_limit_exceeded", "skipped"] },
269
269
  "skipped": { "type": "boolean" },
270
270
  "verification_plan_id": {
271
271
  "type": ["string", "null"],
@@ -1,6 +1,6 @@
1
1
  id = "default"
2
2
  name = "default"
3
- version = "2.18.0"
3
+ version = "2.18.2"
4
4
  description = "Minimal workflow for LLM agents to read, edit, and verify their work in a repository."
5
5
  common_root = "common"
6
6
  locales_root = "locales"