@yemi33/minions 0.1.1813 → 0.1.1814

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1814 (2026-05-09)
4
+
5
+ ### Features
6
+ - harden PRD and playbook paths (#2247)
7
+
3
8
  ## 0.1.1813 (2026-05-09)
4
9
 
5
10
  ### Features
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-09T09:32:10.535Z"
4
+ "cachedAt": "2026-05-09T15:01:20.730Z"
5
5
  }
@@ -390,11 +390,10 @@ function executeTaskStage(stage, stageState, run, config, pipeline = {}) {
390
390
  touchedProjects.push(project?.name || null);
391
391
  }
392
392
  }
393
- const projects = [...new Set(touchedProjects)];
394
- return {
395
- status: PIPELINE_STATUS.RUNNING,
396
- artifacts: { workItems: [...new Set(createdIds)], projects },
397
- };
393
+ const artifacts = { workItems: [...new Set(createdIds)] };
394
+ const projectArtifacts = [...new Set(touchedProjects.filter(Boolean))];
395
+ if (projectArtifacts.length > 0) artifacts.projects = projectArtifacts;
396
+ return { status: PIPELINE_STATUS.RUNNING, artifacts };
398
397
  }
399
398
 
400
399
  function executeTaskStageLegacy(stage, stageState, run, config) {
@@ -342,21 +342,41 @@ function isSafeProjectPlaybookName(projectName) {
342
342
  !/^[a-zA-Z]:/.test(name);
343
343
  }
344
344
 
345
+ function isSafePlaybookType(playbookType) {
346
+ const name = String(playbookType || '').trim();
347
+ return !!name &&
348
+ !name.includes('\0') &&
349
+ !name.includes('..') &&
350
+ !/[\\/]/.test(name) &&
351
+ !path.isAbsolute(name) &&
352
+ !/^[a-zA-Z]:/.test(name);
353
+ }
354
+
345
355
  function resolvePlaybookPath(projectName, playbookType) {
356
+ const playbookTypeName = String(playbookType || '').trim();
357
+ if (!isSafePlaybookType(playbookTypeName)) {
358
+ log('warn', `Skipping unsafe playbook type lookup: ${playbookTypeName.replace(/\0/g, '')}`);
359
+ return path.join(PLAYBOOKS_DIR, '__invalid__.md');
360
+ }
346
361
  if (projectName) {
347
362
  const projectDirName = String(projectName).trim();
348
363
  if (isSafeProjectPlaybookName(projectDirName)) {
349
- const localPath = path.join(MINIONS_DIR, 'projects', projectDirName, 'playbooks', `${playbookType}.md`);
364
+ const projectsDir = path.join(MINIONS_DIR, 'projects');
365
+ const localPath = path.resolve(projectsDir, projectDirName, 'playbooks', `${playbookTypeName}.md`);
366
+ if (!shared.isPathInside(localPath, projectsDir)) {
367
+ log('warn', `Skipping project-local playbook outside projects/: ${projectDirName}`);
368
+ return path.join(PLAYBOOKS_DIR, `${playbookTypeName}.md`);
369
+ }
350
370
  try {
351
371
  fs.accessSync(localPath, fs.constants.R_OK);
352
- log('info', `Using project-local playbook: projects/${projectDirName}/playbooks/${playbookType}.md`);
372
+ log('info', `Using project-local playbook: projects/${projectDirName}/playbooks/${playbookTypeName}.md`);
353
373
  return localPath;
354
374
  } catch { /* no local override — fall through to global */ }
355
375
  } else {
356
376
  log('warn', `Skipping project-local playbook lookup for unsafe project name: ${projectDirName}`);
357
377
  }
358
378
  }
359
- return path.join(PLAYBOOKS_DIR, `${playbookType}.md`);
379
+ return path.join(PLAYBOOKS_DIR, `${playbookTypeName}.md`);
360
380
  }
361
381
 
362
382
 
@@ -11,13 +11,84 @@ const shared = require('./shared');
11
11
  const dispatch = require('./dispatch');
12
12
  const { MINIONS_DIR } = shared;
13
13
 
14
- /**
15
- * @param {object} d - dispatch entry
16
- * @returns {string} project name from any of the three meta shapes the engine
17
- * uses (item.project, project.name, project string)
18
- */
19
- function _dispatchProjectName(d) {
20
- return d?.meta?.item?.project || d?.meta?.project?.name || d?.meta?.project || '';
14
+ function _sameProjectName(a, b) {
15
+ return String(a || '').toLowerCase() === String(b || '').toLowerCase();
16
+ }
17
+
18
+ function _projectRefMatches(projectRef, removedProject, projects) {
19
+ if (!projectRef) return false;
20
+ const refs = (projectRef && typeof projectRef === 'object')
21
+ ? [projectRef.name, projectRef.localPath]
22
+ : [projectRef];
23
+ return refs.some(ref => {
24
+ if (!ref) return false;
25
+ const result = shared.resolveConfiguredProject(ref, projects);
26
+ return !!(result.project && _sameProjectName(result.project.name, removedProject.name));
27
+ });
28
+ }
29
+
30
+ function _workItemMatchesProject(item, removedProject, projects) {
31
+ const refs = [
32
+ item?.project,
33
+ item?._project,
34
+ item?.planProject,
35
+ item?._planProject,
36
+ item?._declaredPlanProject,
37
+ ];
38
+ return refs.some(ref => _projectRefMatches(ref, removedProject, projects));
39
+ }
40
+
41
+ function _isCentralDispatchSource(source) {
42
+ return source === 'central-work-item' || source === 'central-work-item-fanout';
43
+ }
44
+
45
+ function _dispatchMatchesProject(d, removedProject, projects) {
46
+ const meta = d?.meta || {};
47
+ if (_isCentralDispatchSource(meta.source)) {
48
+ return _workItemMatchesProject(meta.item, removedProject, projects) ||
49
+ _projectRefMatches(meta.project, removedProject, projects);
50
+ }
51
+ return _workItemMatchesProject(meta.item, removedProject, projects) ||
52
+ _projectRefMatches(meta.project, removedProject, projects);
53
+ }
54
+
55
+ function _centralDispatchDefaultedToProject(d, removedProject, projects) {
56
+ const meta = d?.meta || {};
57
+ return _isCentralDispatchSource(meta.source) &&
58
+ meta.item?.id &&
59
+ !_workItemMatchesProject(meta.item, removedProject, projects) &&
60
+ _projectRefMatches(meta.project, removedProject, projects);
61
+ }
62
+
63
+ function _collectProjectlessCentralDispatchItemIds(removedProject, projects) {
64
+ const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
65
+ const ids = new Set();
66
+ const state = shared.safeJson(dispatchPath) || {};
67
+ for (const queue of ['pending', 'active']) {
68
+ for (const d of Array.isArray(state?.[queue]) ? state[queue] : []) {
69
+ if (_centralDispatchDefaultedToProject(d, removedProject, projects)) ids.add(d.meta.item.id);
70
+ }
71
+ }
72
+ return ids;
73
+ }
74
+
75
+ function _requeueProjectlessCentralWorkItems(itemIds) {
76
+ if (!itemIds || itemIds.size === 0) return 0;
77
+ const wiPath = path.join(MINIONS_DIR, 'work-items.json');
78
+ if (!fs.existsSync(wiPath)) return 0;
79
+ let requeued = 0;
80
+ shared.mutateWorkItems(wiPath, items => {
81
+ if (!Array.isArray(items)) return items;
82
+ for (const w of items) {
83
+ if (!itemIds.has(w?.id) || w.status !== shared.WI_STATUS.DISPATCHED) continue;
84
+ w.status = shared.WI_STATUS.PENDING;
85
+ delete w.dispatched_at;
86
+ delete w.dispatched_to;
87
+ requeued++;
88
+ }
89
+ return items;
90
+ });
91
+ return requeued;
21
92
  }
22
93
 
23
94
  /**
@@ -56,9 +127,10 @@ function removeProject(target, options = {}) {
56
127
  try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
57
128
  catch (e) { return { ...summary, error: 'Failed to read config: ' + e.message }; }
58
129
 
59
- const project = shared.findProjectByNameOrPath(config.projects || [], target);
130
+ const projects = shared.getProjects(config);
131
+ const project = shared.resolveConfiguredProject(target, projects).project;
60
132
  if (!project) {
61
- const available = (config.projects || []).map(p => p.name).join(', ') || '(none)';
133
+ const available = projects.map(p => p.name).join(', ') || '(none)';
62
134
  return { ...summary, error: `No project linked matching: ${target}. Available: ${available}` };
63
135
  }
64
136
  summary.project = { name: project.name, localPath: project.localPath };
@@ -72,15 +144,17 @@ function removeProject(target, options = {}) {
72
144
  );
73
145
  summary.cancelledItems += dispatch.cancelPendingWorkItems(
74
146
  path.join(MINIONS_DIR, 'work-items.json'),
75
- w => String(w.project || '').toLowerCase() === String(project.name || '').toLowerCase(),
147
+ w => _workItemMatchesProject(w, project, projects),
76
148
  'project-removed',
77
149
  );
78
150
 
79
151
  // 2. Drain dispatch — also kills active agent processes and unlinks pid +
80
152
  // prompt sidecars in engine/tmp/, matching what plan delete does.
153
+ const projectlessCentralItemIds = _collectProjectlessCentralDispatchItemIds(project, projects);
81
154
  summary.drainedDispatches = dispatch.cleanDispatchEntries(
82
- d => String(_dispatchProjectName(d) || '').toLowerCase() === String(project.name || '').toLowerCase(),
155
+ d => _dispatchMatchesProject(d, project, projects),
83
156
  );
157
+ _requeueProjectlessCentralWorkItems(projectlessCentralItemIds);
84
158
 
85
159
  // 3. Clean up worktrees under this project's worktree root, honoring
86
160
  // config.engine.worktreeRoot (mirrors lifecycle.js cleanupPlanWorktrees).
@@ -103,7 +177,7 @@ function removeProject(target, options = {}) {
103
177
  // specifically. Don't touch schedules with project='any' or unset.
104
178
  if (Array.isArray(config.schedules)) {
105
179
  for (const s of config.schedules) {
106
- if (shared.resolveProjectSource(s.project, [project], { allowCentral: false }).project && s.enabled !== false) {
180
+ if (_projectRefMatches(s.project, project, projects) && s.enabled !== false) {
107
181
  s.enabled = false;
108
182
  summary.disabledSchedules++;
109
183
  }
@@ -129,7 +203,7 @@ function removeProject(target, options = {}) {
129
203
  for (const f of fs.readdirSync(prdDir).filter(f => f.endsWith('.json'))) {
130
204
  try {
131
205
  const prd = JSON.parse(fs.readFileSync(path.join(prdDir, f), 'utf8'));
132
- if (prd?.project !== project.name) continue;
206
+ if (!_projectRefMatches(prd?.project, project, projects)) continue;
133
207
  archivePlan(f, prd, [project], config);
134
208
  summary.archivedPlans.push('prd/' + f);
135
209
  if (prd.source_plan) archivedSourcePlans.add(prd.source_plan);
@@ -164,14 +238,17 @@ function removeProject(target, options = {}) {
164
238
  ...(p.monitoredResources || []),
165
239
  ...((p.stages || []).flatMap(s => s.monitoredResources || [])),
166
240
  ];
167
- if (refs.some(r => r && shared.resolveProjectSource(r.project || r._project, [project], { allowCentral: false }).project)) {
241
+ if (refs.some(r => r && (
242
+ _projectRefMatches(r.project, project, projects) ||
243
+ _projectRefMatches(r._project, project, projects)
244
+ ))) {
168
245
  summary.pipelineRefs.push(p.id);
169
246
  }
170
247
  }
171
248
  } catch { /* pipelines optional */ }
172
249
 
173
250
  // 7. Remove from config.json (and persist any schedule disables)
174
- config.projects = (config.projects || []).filter(p => !shared.resolveProjectSource(p, [project], { allowCentral: false }).project);
251
+ config.projects = (config.projects || []).filter(p => !_sameProjectName(p?.name, project.name));
175
252
  try { fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); }
176
253
  catch (e) { return { ...summary, error: 'Failed to write config: ' + e.message }; }
177
254
 
package/engine/shared.js CHANGED
@@ -2209,7 +2209,8 @@ function sanitizePath(file, baseDir) {
2209
2209
  if (path.isAbsolute(file) || /^[a-zA-Z]:/.test(file)) throw new Error('invalid file path: absolute path not allowed');
2210
2210
  const resolved = path.resolve(baseDir, file);
2211
2211
  const normalizedBase = path.resolve(baseDir);
2212
- if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) {
2212
+ const rel = path.relative(normalizedBase, resolved);
2213
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
2213
2214
  throw new Error('invalid file path: outside allowed directory');
2214
2215
  }
2215
2216
  return resolved;
package/engine.js CHANGED
@@ -2015,8 +2015,11 @@ function safePrdProjectSlug(projectName) {
2015
2015
 
2016
2016
  function safePrdFilenameForProject(projectName, suffix) {
2017
2017
  const fileName = `${safePrdProjectSlug(projectName)}-${suffix}.json`;
2018
- shared.sanitizePath(fileName, PRD_DIR);
2019
- return fileName;
2018
+ const resolved = shared.sanitizePath(fileName, PRD_DIR);
2019
+ if (path.dirname(resolved) !== path.resolve(PRD_DIR)) {
2020
+ throw new Error('invalid PRD filename: nested paths are not allowed');
2021
+ }
2022
+ return path.basename(resolved);
2020
2023
  }
2021
2024
 
2022
2025
  function materializePlansAsWorkItems(config) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1813",
3
+ "version": "0.1.1814",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"