mustflow 2.18.21 → 2.21.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 (46) hide show
  1. package/dist/cli/commands/classify.js +2 -3
  2. package/dist/cli/commands/doctor.js +46 -6
  3. package/dist/cli/commands/run/output.js +1 -1
  4. package/dist/cli/commands/run/receipt.js +1 -0
  5. package/dist/cli/commands/run.js +3 -1
  6. package/dist/cli/commands/verify.js +15 -10
  7. package/dist/cli/i18n/en.js +1 -0
  8. package/dist/cli/i18n/es.js +1 -0
  9. package/dist/cli/i18n/fr.js +1 -0
  10. package/dist/cli/i18n/hi.js +1 -0
  11. package/dist/cli/i18n/ko.js +1 -0
  12. package/dist/cli/i18n/zh.js +1 -0
  13. package/dist/cli/lib/filesystem.js +3 -96
  14. package/dist/cli/lib/local-index/index.js +4 -4
  15. package/dist/cli/lib/repo-map.js +3 -2
  16. package/dist/cli/lib/run-plan.js +8 -4
  17. package/dist/core/check-issues.js +1 -1
  18. package/dist/core/command-contract-validation.js +24 -10
  19. package/dist/core/command-effects.js +3 -4
  20. package/dist/core/command-output-limits.js +2 -1
  21. package/dist/core/line-endings.js +12 -4
  22. package/dist/core/repeated-failure.js +3 -3
  23. package/dist/core/run-performance-history.js +4 -4
  24. package/dist/core/run-profile.js +2 -3
  25. package/dist/core/run-receipt.js +11 -3
  26. package/dist/core/run-write-drift.js +67 -15
  27. package/dist/core/safe-filesystem.js +158 -0
  28. package/package.json +1 -1
  29. package/schemas/commands.schema.json +1 -0
  30. package/schemas/doctor-report.schema.json +23 -1
  31. package/schemas/run-receipt.schema.json +6 -2
  32. package/templates/default/i18n.toml +13 -13
  33. package/templates/default/locales/en/.mustflow/skills/INDEX.md +13 -13
  34. package/templates/default/locales/en/.mustflow/skills/adapter-boundary/SKILL.md +72 -4
  35. package/templates/default/locales/en/.mustflow/skills/command-contract-authoring/SKILL.md +16 -10
  36. package/templates/default/locales/en/.mustflow/skills/command-pattern/SKILL.md +64 -7
  37. package/templates/default/locales/en/.mustflow/skills/database-change-safety/SKILL.md +249 -16
  38. package/templates/default/locales/en/.mustflow/skills/dependency-reality-check/SKILL.md +37 -7
  39. package/templates/default/locales/en/.mustflow/skills/migration-safety-check/SKILL.md +74 -10
  40. package/templates/default/locales/en/.mustflow/skills/performance-budget-check/SKILL.md +132 -5
  41. package/templates/default/locales/en/.mustflow/skills/pure-core-imperative-shell/SKILL.md +12 -5
  42. package/templates/default/locales/en/.mustflow/skills/result-option/SKILL.md +4 -2
  43. package/templates/default/locales/en/.mustflow/skills/security-privacy-review/SKILL.md +112 -29
  44. package/templates/default/locales/en/.mustflow/skills/state-machine-pattern/SKILL.md +17 -4
  45. package/templates/default/locales/en/.mustflow/skills/structure-discovery-gate/SKILL.md +193 -2
  46. package/templates/default/manifest.toml +1 -1
@@ -1,6 +1,6 @@
1
- import { mkdirSync, writeFileSync } from 'node:fs';
2
1
  import path from 'node:path';
3
2
  import { createChangeClassificationReport, } from '../../core/change-classification.js';
3
+ import { writeJsonFileInsideWithoutSymlinks } from '../../core/safe-filesystem.js';
4
4
  import { printUsageError, renderHelp } from '../lib/cli-output.js';
5
5
  import { requireGitChangedFiles } from '../lib/git-changes.js';
6
6
  import { t } from '../lib/i18n.js';
@@ -111,8 +111,7 @@ function resolveWritePath(projectRoot, inputPath) {
111
111
  }
112
112
  function writeClassifyOutput(projectRoot, inputPath, output) {
113
113
  const outputPath = resolveWritePath(projectRoot, inputPath);
114
- mkdirSync(path.dirname(outputPath), { recursive: true });
115
- writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`, 'utf8');
114
+ writeJsonFileInsideWithoutSymlinks(projectRoot, outputPath, output);
116
115
  }
117
116
  export function runClassify(args, reporter, lang = 'en') {
118
117
  if (args.includes('--help') || args.includes('-h')) {
@@ -5,7 +5,9 @@ import { getAgentContext, } from '../lib/agent-context.js';
5
5
  import { t } from '../lib/i18n.js';
6
6
  import { getLocalIndexDatabasePath } from '../lib/local-index.js';
7
7
  import { resolveMustflowRoot } from '../lib/project-root.js';
8
- import { checkMustflowProject } from '../lib/validation.js';
8
+ import { checkMustflowProjectReport } from '../lib/validation.js';
9
+ import { findCommandEnvInheritanceWarnings } from '../../core/command-contract-validation.js';
10
+ import { readCommandContract } from '../../core/config-loading.js';
9
11
  import { summarizeSkillRouteAlignment } from '../../core/skill-route-alignment.js';
10
12
  const DOCTOR_SCHEMA_VERSION = '1';
11
13
  export function getDoctorHelp(lang = 'en') {
@@ -46,6 +48,7 @@ function createDiagnostics(output) {
46
48
  const localIndexExists = existsSync(getLocalIndexDatabasePath(output.mustflow_root));
47
49
  const runnableIntentCount = output.context.runnable_intents.length;
48
50
  const skillRouteAlignment = summarizeSkillRouteAlignment(output.check.issues);
51
+ const inheritedEnvIntentCount = output.command_environment.inherited_intents.length;
49
52
  diagnostics.push({
50
53
  id: 'install',
51
54
  status: output.installed ? 'ok' : 'fail',
@@ -55,7 +58,9 @@ function createDiagnostics(output) {
55
58
  diagnostics.push({
56
59
  id: 'validation',
57
60
  status: output.check.ok ? 'ok' : 'fail',
58
- summary: `${output.check.issue_count} ${pluralize(output.check.issue_count, 'issue', 'issues')}`,
61
+ summary: output.check.warning_count === 0
62
+ ? `${output.check.issue_count} ${pluralize(output.check.issue_count, 'issue', 'issues')}`
63
+ : `${output.check.issue_count} ${pluralize(output.check.issue_count, 'issue', 'issues')}, ${output.check.warning_count} ${pluralize(output.check.warning_count, 'warning', 'warnings')}`,
59
64
  action: output.check.ok ? null : checkCommand,
60
65
  });
61
66
  diagnostics.push({
@@ -72,6 +77,16 @@ function createDiagnostics(output) {
72
77
  : 'missing',
73
78
  action: output.context.command_contract_exists ? 'mf help commands' : 'mf init --dry-run',
74
79
  });
80
+ diagnostics.push({
81
+ id: 'environment',
82
+ status: output.context.command_contract_exists ? (inheritedEnvIntentCount > 0 ? 'warn' : 'ok') : 'info',
83
+ summary: output.context.command_contract_exists
84
+ ? inheritedEnvIntentCount === 0
85
+ ? 'no inherited host-environment intents'
86
+ : `${inheritedEnvIntentCount} inherited host-environment ${pluralize(inheritedEnvIntentCount, 'intent', 'intents')}: ${output.command_environment.inherited_intents.join(', ')}`
87
+ : 'command contract missing',
88
+ action: inheritedEnvIntentCount > 0 ? 'mf check --strict --json' : null,
89
+ });
75
90
  diagnostics.push({
76
91
  id: 'read_order',
77
92
  status: output.context.missing_read_order.length === 0 ? 'ok' : 'fail',
@@ -124,15 +139,37 @@ function getNextSteps(output) {
124
139
  }
125
140
  return nextSteps;
126
141
  }
142
+ function readCommandEnvironmentSummary(projectRoot) {
143
+ try {
144
+ const contract = readCommandContract(projectRoot);
145
+ const warnings = findCommandEnvInheritanceWarnings(contract);
146
+ return {
147
+ inherited_intents: warnings.map((warning) => warning.intentName).sort((left, right) => left.localeCompare(right)),
148
+ inherited_network_intents: warnings
149
+ .filter((warning) => warning.network)
150
+ .map((warning) => warning.intentName)
151
+ .sort((left, right) => left.localeCompare(right)),
152
+ };
153
+ }
154
+ catch {
155
+ return {
156
+ inherited_intents: [],
157
+ inherited_network_intents: [],
158
+ };
159
+ }
160
+ }
127
161
  function createDoctorOutput(strict) {
128
162
  const mustflowRoot = resolveMustflowRoot();
129
163
  const context = getAgentContext(mustflowRoot);
130
- const issues = checkMustflowProject(mustflowRoot, { strict });
164
+ const checkReport = checkMustflowProjectReport(mustflowRoot, { strict });
131
165
  const check = {
132
- ok: issues.length === 0,
133
- issue_count: issues.length,
134
- issues,
166
+ ok: checkReport.issues.length === 0,
167
+ issue_count: checkReport.issues.length,
168
+ issues: checkReport.issues,
169
+ warning_count: checkReport.warnings.length,
170
+ warnings: checkReport.warnings,
135
171
  };
172
+ const commandEnvironment = readCommandEnvironmentSummary(mustflowRoot);
136
173
  const baseOutput = {
137
174
  schema_version: DOCTOR_SCHEMA_VERSION,
138
175
  command: 'doctor',
@@ -150,6 +187,7 @@ function createDoctorOutput(strict) {
150
187
  missing_optional_read_order: context.optional_read_order.filter((entry) => !entry.exists).map((entry) => entry.path),
151
188
  latest_run_exists: context.latest_run.exists,
152
189
  },
190
+ command_environment: commandEnvironment,
153
191
  effective_policy: context.effective_policy,
154
192
  state_policy: context.state_policy,
155
193
  blocked_actions: context.blocked_actions,
@@ -171,6 +209,8 @@ function getDiagnosticLabel(id, lang) {
171
209
  return t(lang, 'doctor.diagnostic.skillRoutes');
172
210
  case 'commands':
173
211
  return t(lang, 'doctor.diagnostic.commands');
212
+ case 'environment':
213
+ return t(lang, 'doctor.diagnostic.environment');
174
214
  case 'read_order':
175
215
  return t(lang, 'doctor.diagnostic.readOrder');
176
216
  case 'optional_read_order':
@@ -46,7 +46,7 @@ export function writeStreamChunk(reporter, stream, chunk) {
46
46
  reporter.stderr(chunk.toString());
47
47
  }
48
48
  export function createOutputLimitError(stream, maxOutputBytes) {
49
- return Object.assign(new Error(`${stream} exceeded max_output_bytes (${maxOutputBytes})`), {
49
+ return Object.assign(new Error(`${stream} exceeded per-stream max_output_bytes (${maxOutputBytes})`), {
50
50
  code: OUTPUT_LIMIT_ERROR_CODE,
51
51
  });
52
52
  }
@@ -16,6 +16,7 @@ export function assembleRunReceipt(input) {
16
16
  envPolicy: input.plan.envPolicy,
17
17
  envAllowlist: input.plan.envAllowlist,
18
18
  timeoutSeconds: input.plan.timeoutSeconds,
19
+ killAfterSeconds: input.plan.killAfterSeconds,
19
20
  maxOutputBytes: input.plan.maxOutputBytes,
20
21
  successExitCodes: input.plan.successExitCodes,
21
22
  exitCode: input.exitCode,
@@ -171,7 +171,9 @@ export async function runRun(args, reporter, lang = 'en', options = {}) {
171
171
  }
172
172
  const runReceiptPolicy = profiler.measure('retention_policy', () => resolveRunReceiptRetentionPolicy(readMustflowConfigIfExists(projectRoot)));
173
173
  const env = profiler.measure('environment', () => createCommandEnv(projectRoot, { policy: plan.envPolicy, allowlist: plan.envAllowlist }));
174
- const writeTracker = profiler.measure('write_drift_before', () => startRunWriteTracking(projectRoot, contract, intentName));
174
+ const writeTracker = profiler.measure('write_drift_before', () => startRunWriteTracking(projectRoot, contract, intentName, {
175
+ additionalDeclaredPaths: options.additionalDeclaredWritePaths,
176
+ }));
175
177
  const stdoutTailBytes = Math.min(runReceiptPolicy.stdoutTailBytes, plan.maxOutputBytes);
176
178
  const stderrTailBytes = Math.min(runReceiptPolicy.stderrTailBytes, plan.maxOutputBytes);
177
179
  let streamedOutput = false;
@@ -1,11 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { readFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { createClassifyOutput } from './classify.js';
5
5
  import { runRun } from './run.js';
6
6
  import { createChangeVerificationReport, } from '../../core/change-verification.js';
7
+ import { writeJsonFileInsideWithoutSymlinks } from '../../core/safe-filesystem.js';
7
8
  import { createVerifyCompletionVerdict, } from '../../core/completion-verdict.js';
8
- import { atomicWriteJsonFile, createStateRunId } from '../../core/atomic-state-write.js';
9
+ import { createStateRunId } from '../../core/atomic-state-write.js';
9
10
  import { createExternalEvidenceRisks, } from '../../core/external-evidence.js';
10
11
  import { createRepeatedFailureRisks, createVerificationFailureFingerprint, updateRepeatedFailureState, } from '../../core/repeated-failure.js';
11
12
  import { countReproEvidenceVerdictEffects, createReproEvidenceRisks, } from '../../core/repro-evidence.js';
@@ -545,8 +546,7 @@ function createInputFromChanged(projectRoot) {
545
546
  }
546
547
  function writeChangedPlan(projectRoot, inputPath, plan) {
547
548
  const planPath = resolvePlanPath(projectRoot, inputPath);
548
- mkdirSync(path.dirname(planPath), { recursive: true });
549
- writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`, 'utf8');
549
+ writeJsonFileInsideWithoutSymlinks(projectRoot, planPath, plan);
550
550
  }
551
551
  export function planErrorMessageKey(code) {
552
552
  switch (code) {
@@ -628,11 +628,12 @@ function testTargetsByScheduledIntent(report) {
628
628
  candidate.appliedTestTargets.length > 0)
629
629
  .map((candidate) => [candidate.intent, candidate.appliedTestTargets]));
630
630
  }
631
- async function runVerificationIntent(intent, lang, verificationPlanId, testTargets = []) {
631
+ async function runVerificationIntent(intent, lang, verificationPlanId, testTargets = [], additionalDeclaredWritePaths = []) {
632
632
  const output = createBufferedOutput();
633
633
  const exitCode = await runRun([intent, '--json'], output.reporter, lang, {
634
634
  writeLatestReceipt: false,
635
635
  testTargets,
636
+ additionalDeclaredWritePaths,
636
637
  });
637
638
  const rawStdout = output.stdout().trim();
638
639
  let receipt = null;
@@ -680,7 +681,12 @@ async function runVerificationEntriesInParallelChunks(entries, parallelism, lang
680
681
  const results = [];
681
682
  for (let index = 0; index < entries.length; index += parallelism) {
682
683
  const chunk = entries.slice(index, index + parallelism);
683
- results.push(...(await Promise.all(chunk.map((entry) => runVerificationIntent(entry.intent, lang, verificationPlanId, scheduledTestTargets.get(entry.intent) ?? [])))));
684
+ const batchDeclaredWritePaths = [
685
+ ...new Set(chunk.flatMap((entry) => entry.effects
686
+ .filter((effect) => effect.access === 'write' && typeof effect.path === 'string')
687
+ .map((effect) => effect.path))),
688
+ ].sort((left, right) => left.localeCompare(right));
689
+ results.push(...(await Promise.all(chunk.map((entry) => runVerificationIntent(entry.intent, lang, verificationPlanId, scheduledTestTargets.get(entry.intent) ?? [], batchDeclaredWritePaths)))));
684
690
  }
685
691
  return results;
686
692
  }
@@ -1059,7 +1065,6 @@ function writeVerifyRunReceipts(projectRoot, output, report, sourceAnchorRisks,
1059
1065
  const statePaths = createVerifyRunStatePaths(projectRoot);
1060
1066
  const receipts = [];
1061
1067
  const results = [];
1062
- mkdirSync(statePaths.absoluteIntentDir, { recursive: true });
1063
1068
  for (const [index, result] of output.results.entries()) {
1064
1069
  let receiptPath = null;
1065
1070
  let receiptSha256 = null;
@@ -1075,7 +1080,7 @@ function writeVerifyRunReceipts(projectRoot, output, report, sourceAnchorRisks,
1075
1080
  };
1076
1081
  const receiptContent = `${JSON.stringify(receipt, null, 2)}\n`;
1077
1082
  receiptSha256 = hashTextSha256(receiptContent);
1078
- atomicWriteJsonFile(absoluteReceiptPath, receipt);
1083
+ writeJsonFileInsideWithoutSymlinks(projectRoot, absoluteReceiptPath, receipt);
1079
1084
  }
1080
1085
  receipts.push({
1081
1086
  intent: result.intent,
@@ -1181,7 +1186,7 @@ function writeVerifyRunReceipts(projectRoot, output, report, sourceAnchorRisks,
1181
1186
  ...(outputWithReceiptPaths.external_checks ? { external_checks: outputWithReceiptPaths.external_checks } : {}),
1182
1187
  receipts,
1183
1188
  };
1184
- atomicWriteJsonFile(statePaths.absoluteManifestPath, manifest);
1189
+ writeJsonFileInsideWithoutSymlinks(projectRoot, statePaths.absoluteManifestPath, manifest);
1185
1190
  const latest = {
1186
1191
  schema_version: '1',
1187
1192
  command: 'verify',
@@ -1202,7 +1207,7 @@ function writeVerifyRunReceipts(projectRoot, output, report, sourceAnchorRisks,
1202
1207
  run_dir: statePaths.runDir,
1203
1208
  manifest_path: statePaths.manifestPath,
1204
1209
  };
1205
- atomicWriteJsonFile(path.join(projectRoot, LATEST_RUN_RECEIPT_PATH), latest);
1210
+ writeJsonFileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, LATEST_RUN_RECEIPT_PATH), latest);
1206
1211
  return outputWithReceiptPaths;
1207
1212
  }
1208
1213
  async function createVerifyOutput(input, planSource, projectRoot, lang, reproEvidence = null, externalChecks = [], parallelism = DEFAULT_VERIFY_PARALLELISM) {
@@ -488,6 +488,7 @@ export const enMessages = {
488
488
  "doctor.diagnostic.validation": "Validation",
489
489
  "doctor.diagnostic.skillRoutes": "Skill routes",
490
490
  "doctor.diagnostic.commands": "Command specification",
491
+ "doctor.diagnostic.environment": "Environment",
491
492
  "doctor.diagnostic.readOrder": "Read order",
492
493
  "doctor.diagnostic.optionalReadOrder": "Optional read order",
493
494
  "doctor.diagnostic.repoMap": "REPO_MAP.md",
@@ -488,6 +488,7 @@ export const esMessages = {
488
488
  "doctor.diagnostic.validation": "Validación",
489
489
  "doctor.diagnostic.skillRoutes": "Rutas de skills",
490
490
  "doctor.diagnostic.commands": "Especificación de comandos",
491
+ "doctor.diagnostic.environment": "Entorno",
491
492
  "doctor.diagnostic.readOrder": "Orden de lectura",
492
493
  "doctor.diagnostic.optionalReadOrder": "Orden de lectura opcional",
493
494
  "doctor.diagnostic.repoMap": "REPO_MAP.md",
@@ -488,6 +488,7 @@ export const frMessages = {
488
488
  "doctor.diagnostic.validation": "Validation",
489
489
  "doctor.diagnostic.skillRoutes": "Routage des skills",
490
490
  "doctor.diagnostic.commands": "Spécification des commandes",
491
+ "doctor.diagnostic.environment": "Environnement",
491
492
  "doctor.diagnostic.readOrder": "Ordre de lecture",
492
493
  "doctor.diagnostic.optionalReadOrder": "Ordre de lecture optionnel",
493
494
  "doctor.diagnostic.repoMap": "REPO_MAP.md",
@@ -488,6 +488,7 @@ export const hiMessages = {
488
488
  "doctor.diagnostic.validation": "सत्यापन",
489
489
  "doctor.diagnostic.skillRoutes": "स्किल रूट",
490
490
  "doctor.diagnostic.commands": "कमांड विनिर्देश",
491
+ "doctor.diagnostic.environment": "एनवायरनमेंट",
491
492
  "doctor.diagnostic.readOrder": "पढ़ने का क्रम",
492
493
  "doctor.diagnostic.optionalReadOrder": "वैकल्पिक पढ़ने का क्रम",
493
494
  "doctor.diagnostic.repoMap": "REPO_MAP.md",
@@ -488,6 +488,7 @@ export const koMessages = {
488
488
  "doctor.diagnostic.validation": "검증",
489
489
  "doctor.diagnostic.skillRoutes": "스킬 라우팅",
490
490
  "doctor.diagnostic.commands": "명령",
491
+ "doctor.diagnostic.environment": "실행 환경",
491
492
  "doctor.diagnostic.readOrder": "읽기 순서",
492
493
  "doctor.diagnostic.optionalReadOrder": "선택적 읽기 순서",
493
494
  "doctor.diagnostic.repoMap": "REPO_MAP.md",
@@ -488,6 +488,7 @@ export const zhMessages = {
488
488
  "doctor.diagnostic.validation": "验证",
489
489
  "doctor.diagnostic.skillRoutes": "技能路由",
490
490
  "doctor.diagnostic.commands": "命令规范",
491
+ "doctor.diagnostic.environment": "环境",
491
492
  "doctor.diagnostic.readOrder": "读取顺序",
492
493
  "doctor.diagnostic.optionalReadOrder": "可选读取顺序",
493
494
  "doctor.diagnostic.repoMap": "REPO_MAP.md",
@@ -1,103 +1,10 @@
1
- import { closeSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, statSync, writeFileSync, } from 'node:fs';
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- const NOFOLLOW_FLAG = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
3
+ export { ensureFileTargetInsideWithoutSymlinks, ensureInside, ensureInsideWithoutSymlinks, readFileInsideWithoutSymlinks, readUtf8FileInsideWithoutSymlinks, writeFileInsideWithoutSymlinks, writeUtf8FileInsideWithoutSymlinks, } from '../../core/safe-filesystem.js';
4
+ import { readFileInsideWithoutSymlinks, writeFileInsideWithoutSymlinks, } from '../../core/safe-filesystem.js';
4
5
  export function toPosixPath(value) {
5
6
  return value.split(path.sep).join('/');
6
7
  }
7
- export function ensureInside(parentPath, childPath) {
8
- const parent = path.resolve(parentPath);
9
- const child = path.resolve(childPath);
10
- const relative = path.relative(parent, child);
11
- if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
12
- return;
13
- }
14
- throw new Error(`Path escapes allowed directory: ${childPath}`);
15
- }
16
- function isMissingPathError(error) {
17
- return error instanceof Error && 'code' in error && error.code === 'ENOENT';
18
- }
19
- export function ensureInsideWithoutSymlinks(parentPath, childPath, options = {}) {
20
- ensureInside(parentPath, childPath);
21
- const parent = path.resolve(parentPath);
22
- const child = path.resolve(childPath);
23
- const relative = path.relative(parent, child);
24
- const segments = relative === '' ? [] : relative.split(path.sep).filter((segment) => segment.length > 0);
25
- let currentPath = parent;
26
- const parentStats = lstatSync(parent);
27
- if (parentStats.isSymbolicLink()) {
28
- throw new Error(`Path must not contain symlinks: ${childPath}`);
29
- }
30
- for (const [index, segment] of segments.entries()) {
31
- currentPath = path.join(currentPath, segment);
32
- const isLeaf = index === segments.length - 1;
33
- try {
34
- const stats = lstatSync(currentPath);
35
- if (stats.isSymbolicLink()) {
36
- throw new Error(`Path must not contain symlinks: ${childPath}`);
37
- }
38
- if (!isLeaf && !stats.isDirectory()) {
39
- throw new Error(`Path component is not a directory: ${currentPath}`);
40
- }
41
- }
42
- catch (error) {
43
- if (isMissingPathError(error) && options.allowMissingLeaf) {
44
- return;
45
- }
46
- throw error;
47
- }
48
- }
49
- }
50
- export function readUtf8FileInsideWithoutSymlinks(parentPath, childPath) {
51
- return readFileInsideWithoutSymlinks(parentPath, childPath).toString('utf8');
52
- }
53
- export function readFileInsideWithoutSymlinks(parentPath, childPath) {
54
- const absoluteChildPath = path.resolve(childPath);
55
- ensureInsideWithoutSymlinks(parentPath, absoluteChildPath);
56
- const fileDescriptor = openSync(absoluteChildPath, constants.O_RDONLY | NOFOLLOW_FLAG);
57
- try {
58
- return readFileSync(fileDescriptor);
59
- }
60
- finally {
61
- closeSync(fileDescriptor);
62
- }
63
- }
64
- export function ensureFileTargetInsideWithoutSymlinks(parentPath, childPath, options = {}) {
65
- const absoluteChildPath = path.resolve(childPath);
66
- ensureInside(parentPath, absoluteChildPath);
67
- ensureInsideWithoutSymlinks(parentPath, path.dirname(absoluteChildPath), { allowMissingLeaf: true });
68
- try {
69
- const stats = lstatSync(absoluteChildPath);
70
- if (stats.isSymbolicLink()) {
71
- throw new Error(`Path must not contain symlinks: ${childPath}`);
72
- }
73
- if (!stats.isFile()) {
74
- throw new Error(`Path must be a regular file: ${childPath}`);
75
- }
76
- }
77
- catch (error) {
78
- if (isMissingPathError(error) && options.allowMissingLeaf) {
79
- return;
80
- }
81
- throw error;
82
- }
83
- }
84
- export function writeUtf8FileInsideWithoutSymlinks(parentPath, childPath, content) {
85
- writeFileInsideWithoutSymlinks(parentPath, childPath, content);
86
- }
87
- export function writeFileInsideWithoutSymlinks(parentPath, childPath, content) {
88
- const absoluteChildPath = path.resolve(childPath);
89
- const directoryPath = path.dirname(absoluteChildPath);
90
- ensureInsideWithoutSymlinks(parentPath, directoryPath, { allowMissingLeaf: true });
91
- mkdirSync(directoryPath, { recursive: true });
92
- ensureFileTargetInsideWithoutSymlinks(parentPath, absoluteChildPath, { allowMissingLeaf: true });
93
- const fileDescriptor = openSync(absoluteChildPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | NOFOLLOW_FLAG);
94
- try {
95
- writeFileSync(fileDescriptor, content);
96
- }
97
- finally {
98
- closeSync(fileDescriptor);
99
- }
100
- }
101
8
  export function copyFileInsideWithoutSymlinks(sourceParentPath, sourcePath, targetParentPath, targetPath) {
102
9
  const content = readFileInsideWithoutSymlinks(sourceParentPath, sourcePath);
103
10
  writeFileInsideWithoutSymlinks(targetParentPath, targetPath, content);
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import path from 'node:path';
4
4
  import { isRecord, readCommandContract, readString, readStringArray } from '../command-contract.js';
@@ -8,6 +8,7 @@ import { collectSourceAnchorIndexRecords, hasHighRiskSourceAnchorRiskTags, } fro
8
8
  import { listSourceAnchorFiles } from '../../../core/source-anchors.js';
9
9
  import { normalizeCommandEffects } from '../../../core/command-effects.js';
10
10
  import { listChangeClassificationRuleDescriptors } from '../../../core/change-classification.js';
11
+ import { writeFileInsideWithoutSymlinks } from '../../../core/safe-filesystem.js';
11
12
  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';
12
13
  import { loadSqlJs } from './sql.js';
13
14
  export function getLocalIndexDatabasePath(projectRoot) {
@@ -2202,8 +2203,7 @@ export async function createLocalIndex(projectRoot, options = {}) {
2202
2203
  const database = new SQL.Database();
2203
2204
  createSchema(database, capabilities);
2204
2205
  populateDatabase(database, capabilities, documents, skills, skillRoutes, commandIntents, sourceAnchors, indexedFiles, verificationEvidence, indexMode, sourceScopeHash, includeSource, new Date().toISOString());
2205
- mkdirSync(path.dirname(databasePath), { recursive: true });
2206
- writeFileSync(databasePath, database.export());
2206
+ writeFileInsideWithoutSymlinks(projectRoot, databasePath, database.export());
2207
2207
  database.close();
2208
2208
  }
2209
2209
  return {
@@ -2245,7 +2245,7 @@ export async function createLocalIndex(projectRoot, options = {}) {
2245
2245
  max_snippet_bytes_per_document: MAX_SNIPPET_BYTES_PER_DOCUMENT,
2246
2246
  excluded_raw_data_kinds: [...LOCAL_INDEX_EXCLUDED_RAW_DATA_KINDS],
2247
2247
  indexed_file_count: indexedFiles.length,
2248
- indexed_paths: documents.map((document) => document.path),
2248
+ indexed_paths: indexedFiles.map((file) => file.path),
2249
2249
  };
2250
2250
  }
2251
2251
  function readStoredSchemaVersion(database) {
@@ -1,8 +1,9 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { existsSync, lstatSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, lstatSync, readdirSync, realpathSync, statSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { toPosixPath } from './filesystem.js';
6
+ import { writeUtf8FileInsideWithoutSymlinks } from '../../core/safe-filesystem.js';
6
7
  import { readTomlFile } from './toml.js';
7
8
  const DEFAULT_DEPTH = 3;
8
9
  const REPO_MAP_DOC_ID = 'repo-map';
@@ -691,5 +692,5 @@ export function generateRepoMap(projectRoot, options = {}) {
691
692
  ].join('\n');
692
693
  }
693
694
  export function writeRepoMap(projectRoot, content) {
694
- writeFileSync(path.join(projectRoot, 'REPO_MAP.md'), content);
695
+ writeUtf8FileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, 'REPO_MAP.md'), content);
695
696
  }
@@ -4,7 +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
+ import { DEFAULT_COMMAND_MAX_OUTPUT_BYTES, COMMAND_OUTPUT_LIMIT_SCOPE, MAX_COMMAND_OUTPUT_BYTES, commandMaxOutputBytesLimitMessage, } from '../../core/command-output-limits.js';
8
8
  import { t } from './i18n.js';
9
9
  function getSuccessExitCodes(intent) {
10
10
  const value = intent.success_exit_codes;
@@ -78,8 +78,10 @@ function readEffectiveMaxOutputBytes(contract, intent) {
78
78
  readPositiveInteger(contract.defaults, 'max_output_bytes') ??
79
79
  DEFAULT_COMMAND_MAX_OUTPUT_BYTES;
80
80
  }
81
- function readEffectiveKillAfterSeconds(contract) {
82
- return readPositiveInteger(contract.defaults, 'kill_after_seconds') ?? 5;
81
+ function readEffectiveKillAfterSeconds(contract, intent) {
82
+ return readPositiveInteger(intent, 'kill_after_seconds') ??
83
+ readPositiveInteger(contract.defaults, 'kill_after_seconds') ??
84
+ 5;
83
85
  }
84
86
  function getMaxOutputBytesLimitDetail(contract, intent) {
85
87
  const intentValue = readPositiveInteger(intent, 'max_output_bytes');
@@ -106,7 +108,7 @@ function readRunIntentMetadata(contract, intent) {
106
108
  kind: readString(intent, 'kind') ?? null,
107
109
  configuredCwd,
108
110
  timeoutSeconds: readPositiveInteger(intent, 'timeout_seconds') ?? null,
109
- killAfterSeconds: readEffectiveKillAfterSeconds(contract),
111
+ killAfterSeconds: readEffectiveKillAfterSeconds(contract, intent),
110
112
  maxOutputBytes: readEffectiveMaxOutputBytes(contract, intent),
111
113
  successExitCodes: getSuccessExitCodes(intent),
112
114
  commandArgv,
@@ -284,7 +286,9 @@ export function createRunPreview(plan, previewMode) {
284
286
  cwd: plan.relativeCwd,
285
287
  resolved_cwd: plan.cwd,
286
288
  timeout_seconds: plan.timeoutSeconds,
289
+ kill_after_seconds: plan.killAfterSeconds,
287
290
  max_output_bytes: plan.maxOutputBytes,
291
+ max_output_bytes_scope: plan.maxOutputBytes === null ? null : COMMAND_OUTPUT_LIMIT_SCOPE,
288
292
  mode: plan.mode,
289
293
  argv: plan.commandArgv,
290
294
  resolved_argv: plan.argvCommand,
@@ -4,7 +4,7 @@ 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
+ ['mustflow.command_contract.max_output_bytes_exceeds_limit', /^\[commands\.(?:defaults|intents\.[^\]]+)\]\.max_output_bytes must be less than or equal to \d+ per output stream$/u],
8
8
  ['mustflow.command_contract.oneshot_stdin_not_closed', /^Oneshot intent [^\s]+ must set stdin = "closed"$/u],
9
9
  ['mustflow.command_contract.long_running_agent_allowed', /^Long-running intent [^\s]+ must not use run_policy = "agent_allowed"$/u],
10
10
  ['mustflow.command_contract.executable_source_missing', /^Configured intent [^\s]+ must define argv or mode = "shell" with cmd$/u],
@@ -161,6 +161,7 @@ function validateCommandIntent(intentName, intent, issues) {
161
161
  validateAllowedStringField(intent, 'env_policy', `[commands.intents.${intentName}].env_policy`, COMMAND_ENV_POLICIES, issues);
162
162
  validateStringArrayField(intent, 'env_allowlist', `[commands.intents.${intentName}].env_allowlist`, issues);
163
163
  validateMaxOutputBytesField(intent, 'max_output_bytes', `[commands.intents.${intentName}].max_output_bytes`, issues);
164
+ validatePositiveIntegerField(intent, 'kill_after_seconds', `[commands.intents.${intentName}].kill_after_seconds`, issues);
164
165
  validateCommandIntentSelection(intentName, intent, issues);
165
166
  if (intent.status !== 'configured') {
166
167
  return;
@@ -223,10 +224,10 @@ function getEffectiveCommandEnvPolicy(defaults, intent) {
223
224
  }
224
225
  return { policy: DEFAULT_COMMAND_ENV_POLICY, source: 'implicit' };
225
226
  }
226
- function validateCommandEnvInheritanceWarnings(commandsToml) {
227
- const issues = [];
227
+ export function findCommandEnvInheritanceWarnings(commandsToml) {
228
+ const warnings = [];
228
229
  if (!commandsToml || !isRecord(commandsToml.intents)) {
229
- return issues;
230
+ return warnings;
230
231
  }
231
232
  const defaults = isRecord(commandsToml.defaults) ? commandsToml.defaults : undefined;
232
233
  for (const [intentName, intent] of Object.entries(commandsToml.intents)) {
@@ -237,13 +238,26 @@ function validateCommandEnvInheritanceWarnings(commandsToml) {
237
238
  if (envPolicy.policy !== 'inherit') {
238
239
  continue;
239
240
  }
240
- const networkScope = intent.network === true ? ' with network = true' : '';
241
- const migration = 'set env_policy = "minimal" or "allowlist" unless broad host state is required';
242
- if (envPolicy.source === 'implicit') {
243
- issues.push(commandContractWarning(`configured agent-runnable intent ${intentName} implicitly inherits the host environment${networkScope}; ${migration}`));
244
- continue;
245
- }
246
- issues.push(commandContractWarning(`configured agent-runnable intent ${intentName} uses env_policy = "inherit"${networkScope}; ${migration}`));
241
+ warnings.push({
242
+ intentName,
243
+ source: envPolicy.source,
244
+ network: intent.network === true,
245
+ });
246
+ }
247
+ return warnings;
248
+ }
249
+ function formatCommandEnvInheritanceWarning(warning) {
250
+ const networkScope = warning.network ? ' with network = true' : '';
251
+ const migration = 'set env_policy = "minimal" or "allowlist" unless broad host state is required';
252
+ if (warning.source === 'implicit') {
253
+ return `configured agent-runnable intent ${warning.intentName} implicitly inherits the host environment${networkScope}; ${migration}`;
254
+ }
255
+ return `configured agent-runnable intent ${warning.intentName} uses env_policy = "inherit"${networkScope}; ${migration}`;
256
+ }
257
+ function validateCommandEnvInheritanceWarnings(commandsToml) {
258
+ const issues = [];
259
+ for (const warning of findCommandEnvInheritanceWarnings(commandsToml)) {
260
+ issues.push(commandContractWarning(formatCommandEnvInheritanceWarning(warning)));
247
261
  }
248
262
  return issues;
249
263
  }
@@ -17,12 +17,11 @@ function validateEffectPath(projectRoot, intent, rawPath) {
17
17
  const cwd = resolveSafeProjectCwd(projectRoot, readString(intent, 'cwd'));
18
18
  const resolved = path.resolve(cwd, rawPath);
19
19
  const root = path.resolve(projectRoot);
20
- const resolvedLower = resolved.toLowerCase();
21
- const rootLower = root.toLowerCase();
22
- if (resolvedLower !== rootLower && !resolvedLower.startsWith(`${rootLower}${path.sep}`)) {
20
+ const relative = path.relative(root, resolved);
21
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
23
22
  throw new Error(`Command effect path must stay inside the current root: ${rawPath}`);
24
23
  }
25
- return normalizeRelativePath(path.relative(projectRoot, resolved));
24
+ return normalizeRelativePath(relative);
26
25
  }
27
26
  function readResourcePaths(commandContract, lock) {
28
27
  const resource = commandContract.resources[lock];
@@ -1,5 +1,6 @@
1
1
  export const DEFAULT_COMMAND_MAX_OUTPUT_BYTES = 1_048_576;
2
2
  export const MAX_COMMAND_OUTPUT_BYTES = 16 * 1024 * 1024;
3
+ export const COMMAND_OUTPUT_LIMIT_SCOPE = 'per_stream';
3
4
  export function commandMaxOutputBytesLimitMessage(label) {
4
- return `${label} must be less than or equal to ${MAX_COMMAND_OUTPUT_BYTES}`;
5
+ return `${label} must be less than or equal to ${MAX_COMMAND_OUTPUT_BYTES} per output stream`;
5
6
  }
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { readFileInsideWithoutSymlinks, writeFileInsideWithoutSymlinks } from './safe-filesystem.js';
4
5
  const GITATTRIBUTES_PATH = '.gitattributes';
5
6
  function toPosixPath(value) {
6
7
  return value.split(path.sep).join('/');
@@ -10,7 +11,7 @@ function hasLfPolicy(projectRoot) {
10
11
  if (!existsSync(attributesPath)) {
11
12
  return false;
12
13
  }
13
- const content = readFileSync(attributesPath, 'utf8');
14
+ const content = readFileInsideWithoutSymlinks(projectRoot, attributesPath).toString('utf8');
14
15
  return /^\*\s+.*(?:^|\s)eol=lf(?:\s|$)/imu.test(content);
15
16
  }
16
17
  function gitList(projectRoot, args) {
@@ -107,14 +108,21 @@ export function inspectLineEndings(projectRoot, mode, options = {}) {
107
108
  if (!existsSync(absolutePath)) {
108
109
  continue;
109
110
  }
110
- const buffer = readFileSync(absolutePath);
111
+ let buffer;
112
+ try {
113
+ buffer = readFileInsideWithoutSymlinks(root, absolutePath);
114
+ }
115
+ catch (error) {
116
+ issues.push(error instanceof Error ? error.message : String(error));
117
+ continue;
118
+ }
111
119
  const lineEnding = detectLineEnding(buffer);
112
120
  const wouldChange = policy === 'lf' && (lineEnding === 'crlf' || lineEnding === 'mixed');
113
121
  if (!wouldChange) {
114
122
  continue;
115
123
  }
116
124
  if (canApply) {
117
- writeFileSync(absolutePath, normalizeLf(buffer));
125
+ writeFileInsideWithoutSymlinks(root, absolutePath, normalizeLf(buffer));
118
126
  changedFiles.push(toPosixPath(relativePath));
119
127
  }
120
128
  nonCompliantFiles.push({