@yemi33/minions 0.1.1985 → 0.1.1987

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/engine/cli.js CHANGED
@@ -57,7 +57,9 @@ function dispatchSafeId(itemId) {
57
57
  }
58
58
 
59
59
  function readDispatchPid(itemId) {
60
- const pidFile = path.join(ENGINE_DIR, 'tmp', `pid-${dispatchSafeId(itemId)}.pid`);
60
+ // P-f6-tmp-toctou: prefer per-dispatch dir layout, fall back to legacy flat.
61
+ const pidFile = shared.findDispatchPidFile(itemId);
62
+ if (!pidFile) return null;
61
63
  let raw;
62
64
  try { raw = fs.readFileSync(pidFile, 'utf8').trim(); }
63
65
  catch { return null; }
@@ -188,6 +190,7 @@ const CLI_COMMAND_DOCS = Object.freeze({
188
190
  doctor: { args: '', summary: 'Check prerequisites and runtime health' },
189
191
  config: { args: 'set-cli <R> [--model M]', summary: 'Persist defaultCli/defaultModel without starting' },
190
192
  pr: { args: 'comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] [--body-file <f>|--body <text>]', summary: 'Post a marker-prepended PR comment via gh' },
193
+ bridge: { args: 'status|health|enable|disable', summary: 'Constellation bridge: toggle and inspect the read-only cross-repo feed' },
191
194
  });
192
195
 
193
196
  function formatCliCommandHelpLines() {
@@ -1567,13 +1570,10 @@ const commands = {
1567
1570
  const shared = require('./shared');
1568
1571
 
1569
1572
  // Kill processes via PID files (expensive — outside dispatch lock).
1570
- // PID files live in engine/tmp/ (see engine/spawn-agent.js:220 derived from
1571
- // the prompt-<id>.md sidecar path that engine.js builds in engine/tmp/).
1572
- // Reading from ENGINE_DIR directly is a no-op: spawn-agent never writes there.
1573
- const pidDir = path.join(ENGINE_DIR, 'tmp');
1574
- const pidFiles = shared.safeReadDir(pidDir).filter(f => f.startsWith('pid-') && f.endsWith('.pid'));
1575
- for (const f of pidFiles) {
1576
- const pidPath = path.join(pidDir, f);
1573
+ // PID files live in engine/tmp/ both legacy flat layout
1574
+ // (`pid-<id>.pid`) and per-dispatch dirs (`dispatch-<id>-XXX/pid-<id>.pid`)
1575
+ // post-P-f6-tmp-toctou. shared.forEachPidFile visits both.
1576
+ shared.forEachPidFile((pidPath, fileName, layout) => {
1577
1577
  const raw = safeRead(pidPath).trim();
1578
1578
  // Guard against falsy/zero/NaN PIDs. Empty pid files would resolve to
1579
1579
  // Number('') === 0, and process.kill(0) on POSIX targets the entire
@@ -1581,13 +1581,17 @@ const commands = {
1581
1581
  let pidNum = NaN;
1582
1582
  try { pidNum = shared.validatePid(raw); } catch { /* invalid — skip */ }
1583
1583
  if (pidNum > 0) {
1584
- try { process.kill(pidNum); console.log(`Killed process ${pidNum} (${f})`); }
1584
+ try { process.kill(pidNum); console.log(`Killed process ${pidNum} (${fileName})`); }
1585
1585
  catch { console.log(`Process ${pidNum} already dead`); }
1586
1586
  } else {
1587
- console.log(`Skipping ${f}: invalid or empty PID`);
1587
+ console.log(`Skipping ${fileName}: invalid or empty PID`);
1588
1588
  }
1589
- try { fs.unlinkSync(pidPath); } catch { /* may not exist */ }
1590
- }
1589
+ if (layout === 'dispatch-dir') {
1590
+ try { shared.removeDispatchTmpDir(path.dirname(pidPath)); } catch { /* may not exist */ }
1591
+ } else {
1592
+ try { fs.unlinkSync(pidPath); } catch { /* may not exist */ }
1593
+ }
1594
+ });
1591
1595
 
1592
1596
  // Atomically read and clear dispatch.active (locked read-modify-write)
1593
1597
  let killed = [];
@@ -1815,6 +1819,116 @@ const commands = {
1815
1819
  console.error(`error: ${e.message}`);
1816
1820
  process.exit(1);
1817
1821
  }
1822
+ },
1823
+
1824
+ // `minions bridge <subcommand>` — Constellation read-only bridge surface.
1825
+ // Owns the on/off flag and the marker-file projection used by the
1826
+ // Constellation agent. Bridge polling logic itself lives in the
1827
+ // Constellation repo (P-wi1-bridge-readonly).
1828
+ //
1829
+ // Subcommands:
1830
+ // status Print enabled flag + last-seen Constellation agent timestamp.
1831
+ // health Probe http://127.0.0.1:7331/api/status and print the same
1832
+ // curated projection the Constellation bridge would consume.
1833
+ // enable Atomically set engine.constellationBridge.enabled = true.
1834
+ // disable Atomically set engine.constellationBridge.enabled = false.
1835
+ bridge(subcmd, ...rest) {
1836
+ const bridge = require('./bridge');
1837
+ const BRIDGE_USAGE = 'Usage: minions bridge <status|health|enable|disable>';
1838
+
1839
+ if (!subcmd || subcmd === 'help' || subcmd === '--help' || subcmd === '-h') {
1840
+ console.log(BRIDGE_USAGE);
1841
+ console.log('');
1842
+ console.log(' status Show enabled flag + last-seen Constellation agent timestamp');
1843
+ console.log(' health Probe http://127.0.0.1:7331/api/status and print bridge projection');
1844
+ console.log(' enable Set engine.constellationBridge.enabled = true');
1845
+ console.log(' disable Set engine.constellationBridge.enabled = false');
1846
+ return;
1847
+ }
1848
+
1849
+ if (rest.length > 0) {
1850
+ console.error(`error: unexpected arguments after bridge ${subcmd}: ${rest.join(' ')}`);
1851
+ process.exit(2);
1852
+ }
1853
+
1854
+ if (subcmd === 'enable' || subcmd === 'disable') {
1855
+ const enabled = subcmd === 'enable';
1856
+ const { previous, current } = bridge.setBridgeEnabled(enabled);
1857
+ const verb = previous === current ? 'already' : 'now';
1858
+ console.log(`bridge: ${verb} ${current ? 'enabled' : 'disabled'}`);
1859
+ console.log(' config: engine.constellationBridge.enabled = ' + current);
1860
+ if (!previous && current) {
1861
+ console.log(' note: Constellation-side bridge must also be enabled to project state.');
1862
+ }
1863
+ return;
1864
+ }
1865
+
1866
+ if (subcmd === 'status') {
1867
+ const config = getConfig();
1868
+ const enabled = bridge.isBridgeEnabled(config);
1869
+ console.log(`bridge: ${enabled ? 'enabled' : 'disabled'}`);
1870
+ console.log(' config: engine.constellationBridge.enabled = ' + enabled);
1871
+ const marker = bridge.readBridgeMarker();
1872
+ if (!marker) {
1873
+ console.log(' marker: no Constellation agent has registered yet');
1874
+ console.log(` (expected at ${bridge.CONSTELLATION_BRIDGE_MARKER_PATH})`);
1875
+ } else {
1876
+ console.log(` last seen: ${bridge.formatRelativeAge(marker.lastSeenAt)} (${marker.lastSeenAt})`);
1877
+ if (marker.agentVersion) console.log(` agent version: ${marker.agentVersion}`);
1878
+ if (marker.source) console.log(` source: ${marker.source}`);
1879
+ }
1880
+ return;
1881
+ }
1882
+
1883
+ if (subcmd === 'health') {
1884
+ const http = require('http');
1885
+ const req = http.get(
1886
+ { hostname: '127.0.0.1', port: 7331, path: '/api/status', timeout: 5000 },
1887
+ (res) => {
1888
+ let body = '';
1889
+ res.setEncoding('utf8');
1890
+ res.on('data', (chunk) => { body += chunk; });
1891
+ res.on('end', () => {
1892
+ if (res.statusCode !== 200) {
1893
+ console.error(`error: dashboard returned HTTP ${res.statusCode}`);
1894
+ process.exit(1);
1895
+ }
1896
+ let parsed;
1897
+ try { parsed = JSON.parse(body); }
1898
+ catch (e) {
1899
+ console.error(`error: dashboard response was not JSON: ${e.message}`);
1900
+ process.exit(1);
1901
+ }
1902
+ const projection = bridge.projectStatusForBridge(parsed);
1903
+ if (!projection) {
1904
+ console.error('error: dashboard /api/status returned an unexpected shape');
1905
+ process.exit(1);
1906
+ }
1907
+ console.log('bridge: dashboard reachable on http://127.0.0.1:7331');
1908
+ console.log(' projection (same fields the Constellation bridge would read):');
1909
+ for (const [k, v] of Object.entries(projection)) {
1910
+ console.log(` ${k}: ${v === null ? '(unknown)' : v}`);
1911
+ }
1912
+ });
1913
+ }
1914
+ );
1915
+ req.on('timeout', () => {
1916
+ req.destroy(new Error('connect timeout'));
1917
+ });
1918
+ req.on('error', (err) => {
1919
+ if (err.code === 'ECONNREFUSED') {
1920
+ console.error('error: dashboard not running on :7331 — start it with `minions dash`');
1921
+ } else {
1922
+ console.error(`error: ${err.message}`);
1923
+ }
1924
+ process.exit(1);
1925
+ });
1926
+ return;
1927
+ }
1928
+
1929
+ console.error(`Unknown bridge subcommand: ${subcmd}`);
1930
+ console.error(BRIDGE_USAGE);
1931
+ process.exit(2);
1818
1932
  }
1819
1933
  };
1820
1934
 
@@ -823,6 +823,7 @@ function cleanDispatchEntries(matchFn) {
823
823
  let removed = 0;
824
824
  const pidsToKill = [];
825
825
  const filesToDelete = [];
826
+ const dispatchDirsToRemove = [];
826
827
  try {
827
828
  mutateJsonFileLocked(dispatchPath, (dispatch) => {
828
829
  for (const queue of ['pending', 'active', 'completed']) {
@@ -831,17 +832,26 @@ function cleanDispatchEntries(matchFn) {
831
832
  if (queue === 'active') {
832
833
  for (const d of dispatch[queue]) {
833
834
  if (!matchFn(d)) continue;
834
- // PID files live in engine/tmp/ (see engine/spawn-agent.js:220 — derived
835
- // from the prompt-<id>.md path that engine.js builds in engine/tmp/).
836
- const pidFile = path.join(tmpDir, `pid-${d.id}.pid`);
837
- try {
838
- const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim());
839
- if (pid) pidsToKill.push(pid);
840
- } catch { /* PID file may not exist */ }
841
- filesToDelete.push(pidFile);
842
- filesToDelete.push(path.join(tmpDir, `prompt-${d.id}.md`));
843
- filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md`));
844
- filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md.tmp`));
835
+ // P-f6-tmp-toctou: prefer the dispatch's recorded tmpDir. Fall
836
+ // back to legacy flat layout for active dispatches that pre-date
837
+ // the per-dispatch-dir layout. shared.findDispatchPidFile honors
838
+ // the same resolution order so we agree on which PID to kill.
839
+ const pidPath = shared.findDispatchPidFile(d);
840
+ if (pidPath) {
841
+ try {
842
+ const pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim());
843
+ if (pid) pidsToKill.push(pid);
844
+ } catch { /* PID file may not exist */ }
845
+ }
846
+ if (d.tmpDir && shared.validateDispatchTmpDir(d.tmpDir)) {
847
+ dispatchDirsToRemove.push(d.tmpDir);
848
+ } else {
849
+ // Legacy individual-file cleanup for pre-migration entries.
850
+ filesToDelete.push(path.join(tmpDir, `pid-${d.id}.pid`));
851
+ filesToDelete.push(path.join(tmpDir, `prompt-${d.id}.md`));
852
+ filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md`));
853
+ filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md.tmp`));
854
+ }
845
855
  }
846
856
  }
847
857
  dispatch[queue] = dispatch[queue].filter(d => !matchFn(d));
@@ -865,6 +875,9 @@ function cleanDispatchEntries(matchFn) {
865
875
  for (const fp of filesToDelete) {
866
876
  try { fs.unlinkSync(fp); } catch { /* may not exist */ }
867
877
  }
878
+ for (const dir of dispatchDirsToRemove) {
879
+ shared.removeDispatchTmpDir(dir);
880
+ }
868
881
  return removed;
869
882
  }
870
883
 
package/engine/github.js CHANGED
@@ -35,6 +35,29 @@ function _setExecAsyncForTest(fn) {
35
35
  _execAsyncOverride = (typeof fn === 'function') ? fn : null;
36
36
  }
37
37
 
38
+ // P-f2-gh-shell (F2): parallel test seam for argv-form (shellSafeGh) shell-outs.
39
+ // The legacy `_runExec` seam intercepts shell-string `gh api ...` invocations,
40
+ // but the F2 conversion routes ghApi / build-log fetch / dismiss-review through
41
+ // shared.shellSafeGh (execFile, shell:false). New tests can mock at the argv
42
+ // level via `_setShellSafeGhForTest`; pre-existing tests that mock the shell-string
43
+ // layer keep working because `_runShellSafeGh` falls back to the legacy override
44
+ // by synthesizing a cmd string from argv (quoting any non-trivial argument so
45
+ // existing `/"repos\/owner\/repo"$/`-style route patterns still match).
46
+ let _shellSafeGhOverride = null;
47
+ function _runShellSafeGh(args, opts) {
48
+ if (_shellSafeGhOverride) return _shellSafeGhOverride(args, opts);
49
+ if (_execAsyncOverride) {
50
+ const cmd = ['gh', ...args]
51
+ .map(a => /^[A-Za-z0-9_=.-]+$/.test(String(a)) ? String(a) : `"${String(a)}"`)
52
+ .join(' ');
53
+ return _execAsyncOverride(cmd, opts);
54
+ }
55
+ return shared.shellSafeGh(args, opts);
56
+ }
57
+ function _setShellSafeGhForTest(fn) {
58
+ _shellSafeGhOverride = (typeof fn === 'function') ? fn : null;
59
+ }
60
+
38
61
  // ─── Constants ──────────────────────────────────────────────────────────────
39
62
 
40
63
  // 10 MB — prevents maxBuffer exceeded errors on repos with many open PRs.
@@ -270,11 +293,15 @@ const GH_NOT_FOUND = Object.freeze({ _notFound: true });
270
293
  */
271
294
  async function ghApi(endpoint, slug, opts = {}) {
272
295
  try {
273
- const paginateFlag = opts.paginate ? ' --paginate' : '';
274
- const cmd = `gh api${paginateFlag} "repos/${slug}${endpoint}"`;
296
+ // P-f2-gh-shell (F2): argv form via shellSafeGh eliminates shell
297
+ // interpolation of slug/endpoint. validateGhSlug + validateGhEndpoint
298
+ // reject shell metacharacters before they reach child_process.
299
+ const args = ['api'];
300
+ if (opts.paginate) args.push('--paginate');
301
+ args.push(`repos/${shared.validateGhSlug(slug)}${shared.validateGhEndpoint(endpoint)}`);
275
302
  const token = ghToken.resolveTokenForSlug(slug);
276
303
  const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
277
- const result = await _runExec(cmd, { timeout: opts.timeout || 30000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER, env });
304
+ const result = await _runShellSafeGh(args, { timeout: opts.timeout || 30000, maxBuffer: GH_MAX_BUFFER, env });
278
305
  const parsed = JSON.parse(result);
279
306
  _ghThrottle.recordSuccess();
280
307
  return parsed;
@@ -340,12 +367,18 @@ async function fetchGhBuildErrorLog(slug, failedRuns) {
340
367
  }
341
368
  } catch { /* fall through to job log */ }
342
369
 
343
- // Always fetch job log — annotations alone often lack test failure details
370
+ // Always fetch job log — annotations alone often lack test failure details.
371
+ // P-f2-gh-shell (F2): argv form via shellSafeGh. validateJobId rejects
372
+ // non-integer run.id values before they reach child_process. Stderr is
373
+ // captured via shellSafeGh's execFile path (stderr surfaces on the thrown
374
+ // Error in the catch block) instead of a `2>&1` shell redirect, which is
375
+ // moot once shell:false is in effect.
344
376
  try {
345
- const cmd = `gh api "repos/${slug}/actions/jobs/${run.id}/logs" 2>&1`;
377
+ const jobId = shared.validateJobId(run.id);
378
+ const args = ['api', `repos/${shared.validateGhSlug(slug)}/actions/jobs/${jobId}/logs`];
346
379
  const token = ghToken.resolveTokenForSlug(slug);
347
380
  const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
348
- const result = await _runExec(cmd, { timeout: 15000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER, env });
381
+ const result = await _runShellSafeGh(args, { timeout: 15000, maxBuffer: GH_MAX_BUFFER, env });
349
382
  if (result && !result.includes('Not Found')) {
350
383
  logParts.push(`--- ${run.name || 'Check'} (log) ---\n${result}`);
351
384
  }
@@ -1235,26 +1268,45 @@ async function dismissPriorViewerChangesRequestedReviews(pr, project) {
1235
1268
  const os = require('os');
1236
1269
  let dismissed = 0;
1237
1270
  let errors = 0;
1238
- for (const reviewId of targets) {
1239
- tmpFile = path.join(os.tmpdir(), `gh-review-dismiss-${process.pid}-${Date.now()}-${reviewId}.json`);
1240
- const body = JSON.stringify({
1241
- message: 'Superseded by Minions re-review (verdict flipped to APPROVE).',
1242
- event: 'DISMISS',
1243
- });
1244
- try {
1245
- fs.writeFileSync(tmpFile, body);
1246
- const cmd = `gh api -X PUT --input "${tmpFile}" "repos/${slug}/pulls/${prNum}/reviews/${reviewId}/dismissals"`;
1247
- const token = ghToken.resolveTokenForSlug(slug);
1248
- const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
1249
- await _runExec(cmd, { timeout: 10000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER, env });
1250
- dismissed += 1;
1251
- log('info', `PR ${pr.id}: dismissed prior CHANGES_REQUESTED review ${reviewId} by ${viewerLogin}`);
1252
- } catch (e) {
1253
- errors += 1;
1254
- log('warn', `PR ${pr.id}: dismiss review ${reviewId} failed: ${e?.message || e}`);
1255
- } finally {
1256
- try { fs.unlinkSync(tmpFile); } catch {}
1257
- tmpFile = null;
1271
+ // P-f6-tmp-toctou: per-call mkdtempSync dir (random 6-char suffix) closes
1272
+ // the predictable-prefix window on <os.tmpdir>/gh-review-dismiss-* that
1273
+ // an attacker could otherwise plant as a symlink.
1274
+ // P-f2-gh-shell: argv form via shellSafeGh validates slug, prNum, and
1275
+ // reviewId so a poisoned PR record can't smuggle shell metacharacters.
1276
+ let scopedDir = null;
1277
+ try {
1278
+ scopedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gh-review-dismiss-'));
1279
+ if (process.platform !== 'win32') {
1280
+ try { fs.chmodSync(scopedDir, 0o700); } catch { /* best-effort */ }
1281
+ }
1282
+ for (const reviewId of targets) {
1283
+ tmpFile = path.join(scopedDir, `dismiss-${reviewId}.json`);
1284
+ const body = JSON.stringify({
1285
+ message: 'Superseded by Minions re-review (verdict flipped to APPROVE).',
1286
+ event: 'DISMISS',
1287
+ });
1288
+ try {
1289
+ fs.writeFileSync(tmpFile, body);
1290
+ const args = [
1291
+ 'api', '-X', 'PUT', '--input', tmpFile,
1292
+ `repos/${shared.validateGhSlug(slug)}/pulls/${shared.validatePrNum(prNum)}/reviews/${shared.validateReviewId(reviewId)}/dismissals`,
1293
+ ];
1294
+ const token = ghToken.resolveTokenForSlug(slug);
1295
+ const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
1296
+ await _runShellSafeGh(args, { timeout: 10000, maxBuffer: GH_MAX_BUFFER, env });
1297
+ dismissed += 1;
1298
+ log('info', `PR ${pr.id}: dismissed prior CHANGES_REQUESTED review ${reviewId} by ${viewerLogin}`);
1299
+ } catch (e) {
1300
+ errors += 1;
1301
+ log('warn', `PR ${pr.id}: dismiss review ${reviewId} failed: ${e?.message || e}`);
1302
+ } finally {
1303
+ try { fs.unlinkSync(tmpFile); } catch {}
1304
+ tmpFile = null;
1305
+ }
1306
+ }
1307
+ } finally {
1308
+ if (scopedDir) {
1309
+ try { fs.rmSync(scopedDir, { recursive: true, force: true }); } catch {}
1258
1310
  }
1259
1311
  }
1260
1312
  return { attempted: true, dismissed, errors };
@@ -1515,5 +1567,6 @@ module.exports = {
1515
1567
  _setCachedViewerLogin, // exported for testing (W-mp3bp0ha000997ab-b backfill)
1516
1568
  _backfillViewerDidAuthor, // exported for testing (W-mp3bp0ha000997ab-b backfill)
1517
1569
  _setExecAsyncForTest, // W-mp5trwh60008386d: test seam to mock `gh api` shell-outs
1570
+ _setShellSafeGhForTest, // P-f2-gh-shell (F2): test seam to mock argv-form gh api calls
1518
1571
  GH_NOT_FOUND, // W-mp5trwh60008386d: exported so tests can assert sentinel propagation
1519
1572
  };
package/engine/issues.js CHANGED
@@ -327,9 +327,19 @@ function createGitHubIssue({
327
327
  const safeTitle = _redactIssueContent(title, { repo, projects: redactionProjects });
328
328
  const safeDescription = _redactIssueContent(description || '', { repo, projects: redactionProjects });
329
329
  const issueBody = `${safeDescription}\n\n---\n_Filed via Minions dashboard_`;
330
- const dir = tmpDir || path.join(__dirname, 'tmp');
331
- fs.mkdirSync(dir, { recursive: true });
332
- const bodyFile = path.join(dir, `bug-body-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
330
+ // P-f6-tmp-toctou: per-call mkdtempSync dir (under engine/tmp/ for
331
+ // collocation with other engine tmp state). Random suffix closes the
332
+ // predictable-prefix window on engine/tmp/bug-body-* that an attacker could
333
+ // otherwise plant as a symlink.
334
+ let scopedDir = null;
335
+ let bodyFile;
336
+ if (tmpDir) {
337
+ fs.mkdirSync(tmpDir, { recursive: true });
338
+ bodyFile = path.join(tmpDir, `bug-body-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
339
+ } else {
340
+ scopedDir = shared.createDispatchTmpDir(`issues-${process.pid}-${Date.now()}`);
341
+ bodyFile = path.join(scopedDir, 'bug-body.md');
342
+ }
333
343
  fs.writeFileSync(bodyFile, issueBody);
334
344
 
335
345
  let resolved;
@@ -377,6 +387,7 @@ function createGitHubIssue({
377
387
  throw new GitHubIssueError(`GitHub issue creation failed: ${conciseGhMessage(e)}`);
378
388
  } finally {
379
389
  try { fs.unlinkSync(bodyFile); } catch {}
390
+ if (scopedDir) shared.removeDispatchTmpDir(scopedDir);
380
391
  }
381
392
  }
382
393
 
@@ -272,30 +272,66 @@ function checkPlanCompletion(meta, config) {
272
272
  continue;
273
273
  }
274
274
 
275
- // Build per-project setup block
275
+ // Build per-project setup block.
276
+ // P-f3-verify-prompt — validate every branch ref before splicing into
277
+ // the bash setup commands the verify agent will execute. Without this,
278
+ // a crafted `plan.feature_branch` (e.g. `main; rm -rf ~`) or PR
279
+ // `branch` field reaches the Bash tool via template literal
280
+ // interpolation. github.js validates pr.branch on API persistence
281
+ // (engine/github.js:612-622), but feature_branch has no upstream
282
+ // validator and mainBranch can fall through unvalidated from project
283
+ // config when `git rev-parse` can't verify it (shared.js:1328). Defense
284
+ // in depth lives here, at the actual interpolation site.
276
285
  let checkoutBlock;
277
286
  let wtPath;
278
287
  if (isSharedBranch) {
288
+ let safeFeatureBranch;
289
+ try {
290
+ safeFeatureBranch = shared.validateGitRef(plan.feature_branch);
291
+ } catch (refErr) {
292
+ log('error', `Plan ${planFile}: verify dispatch rejected for ${projName} — invalid feature_branch ref: ${refErr.message}. Skipping verify WI creation.`);
293
+ continue;
294
+ }
279
295
  wtPath = `${p.localPath}/../worktrees/verify-${projName}-${planSlug}`;
280
- const featureBranch = plan.feature_branch;
281
296
  checkoutBlock = [
282
297
  `# ${projName} — shared-branch: use existing feature branch directly`,
283
298
  `cd "${p.localPath.replace(/\\/g, '/')}"`,
284
- `git fetch origin "${featureBranch}"`,
285
- `git worktree add "${wtPath}" "origin/${featureBranch}" 2>/dev/null || (cd "${wtPath}" && git checkout "${featureBranch}" && git pull origin "${featureBranch}")`,
299
+ `git fetch origin "${safeFeatureBranch}"`,
300
+ `git worktree add "${wtPath}" "origin/${safeFeatureBranch}" 2>/dev/null || (cd "${wtPath}" && git checkout "${safeFeatureBranch}" && git pull origin "${safeFeatureBranch}")`,
286
301
  ].join('\n');
287
302
  } else {
303
+ let safeMainBranch;
304
+ try {
305
+ safeMainBranch = shared.validateGitRef(mainBranch);
306
+ } catch (refErr) {
307
+ log('error', `Plan ${planFile}: verify dispatch rejected for ${projName} — invalid mainBranch ref: ${refErr.message}. Skipping verify WI creation.`);
308
+ continue;
309
+ }
310
+ const safeBranches = [];
311
+ let badPrBranch = null;
312
+ for (const pr of prs) {
313
+ if (!pr.branch) continue;
314
+ try {
315
+ safeBranches.push({ id: pr.id || pr.branch, branch: shared.validateGitRef(pr.branch) });
316
+ } catch (refErr) {
317
+ badPrBranch = { id: pr.id || '(unknown)', raw: pr.branch, err: refErr };
318
+ break;
319
+ }
320
+ }
321
+ if (badPrBranch) {
322
+ log('error', `Plan ${planFile}: verify dispatch rejected for ${projName} — invalid pr.branch[${badPrBranch.id}] ref: ${badPrBranch.err.message}. Skipping verify WI creation.`);
323
+ continue;
324
+ }
288
325
  wtPath = `${p.localPath}/../worktrees/verify-${projName}-${planSlug}-${shared.uid()}`;
289
- const branches = prs.map(pr => pr.branch).filter(Boolean);
290
326
  checkoutBlock = [
291
- `# ${projName} — merge ${branches.length} PR branch(es) into one worktree`,
327
+ `# ${projName} — merge ${safeBranches.length} PR branch(es) into one worktree`,
292
328
  `cd "${p.localPath.replace(/\\/g, '/')}"`,
293
- branches.length > 0
294
- ? `git fetch origin ${branches.map(b => `"${b}"`).join(' ')} "${mainBranch}"`
295
- : `git fetch origin "${mainBranch}"`,
296
- `git worktree add "${wtPath}" "origin/${mainBranch}" 2>/dev/null || (cd "${wtPath}" && git checkout "${mainBranch}" && git pull origin "${mainBranch}")`,
329
+ safeBranches.length > 0
330
+ ? `git fetch origin ${safeBranches.map(({ branch }) => `"${branch}"`).join(' ')} "${safeMainBranch}"`
331
+ : `git fetch origin "${safeMainBranch}"`,
332
+ `git worktree add "${wtPath}" "origin/${safeMainBranch}" 2>/dev/null || (cd "${wtPath}" && git checkout "${safeMainBranch}" && git pull origin "${safeMainBranch}")`,
297
333
  `cd "${wtPath}"`,
298
- ...branches.map(b => `git merge "origin/${b}" --no-edit # ${prs.find(pr => pr.branch === b)?.id || b}`),
334
+ ...safeBranches.map(({ id, branch }) => `git merge "origin/${branch}" --no-edit # ${id}`),
299
335
  ].join('\n');
300
336
  }
301
337
 
package/engine/llm.js CHANGED
@@ -319,10 +319,13 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
319
319
  } = callOpts;
320
320
 
321
321
  const id = uid();
322
- const tmpDir = path.join(ENGINE_DIR, 'tmp');
323
- if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
322
+ // P-f6-tmp-toctou: each direct/indirect LLM call gets its own unguessable
323
+ // tmp dir (mkdtempSync + chmod 0o700 on POSIX). The whole dir is rm'd by
324
+ // cleanupFiles consumers via shared.removeDispatchTmpDir.
325
+ const llmTmpDir = shared.createDispatchTmpDir(`llm-${label || 'call'}-${id}`);
324
326
 
325
327
  const cleanupFiles = [];
328
+ const cleanupDirs = [llmTmpDir];
326
329
  const caps = (runtime && runtime.capabilities) || {};
327
330
  const adapterOpts = {
328
331
  model, maxTurns, allowedTools, effort, sessionId,
@@ -346,7 +349,7 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
346
349
  // via --system-prompt-file (Claude) AND we're not resuming (resumed sessions
347
350
  // already have the sys prompt baked in).
348
351
  if (!sessionId && sysPromptText && caps.systemPromptFile) {
349
- sysTmpPath = path.join(tmpDir, `direct-sys-${id}.md`);
352
+ sysTmpPath = path.join(llmTmpDir, `direct-sys-${id}.md`);
350
353
  fs.writeFileSync(sysTmpPath, sysPromptText);
351
354
  cleanupFiles.push(sysTmpPath);
352
355
  adapterOpts.sysPromptFile = sysTmpPath;
@@ -369,12 +372,12 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
369
372
  } else {
370
373
  try { proc.stdin.write(finalPrompt); proc.stdin.end(); } catch { /* broken pipe */ }
371
374
  }
372
- return { proc, cleanupFiles };
375
+ return { proc, cleanupFiles, cleanupDirs };
373
376
  }
374
377
 
375
378
  // Indirect: use spawn-agent.js (when direct=false or binary cache miss)
376
- const promptPath = path.join(tmpDir, `${label}-prompt-${id}.md`);
377
- const sysPath = path.join(tmpDir, `${label}-sys-${id}.md`);
379
+ const promptPath = path.join(llmTmpDir, `${label}-prompt-${id}.md`);
380
+ const sysPath = path.join(llmTmpDir, `${label}-sys-${id}.md`);
378
381
  // The wrapper merges sys prompt into the user prompt for runtimes without
379
382
  // --system-prompt-file (Copilot) — write the user prompt as `finalPrompt`
380
383
  // (system block already prepended by buildPrompt) for those, and just the
@@ -402,7 +405,7 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
402
405
  const proc = runFile(process.execPath, args, {
403
406
  cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv(),
404
407
  });
405
- return { proc, cleanupFiles };
408
+ return { proc, cleanupFiles, cleanupDirs };
406
409
  }
407
410
 
408
411
  // ─── Streaming Accumulator ───────────────────────────────────────────────────
@@ -621,7 +624,7 @@ function callLLM(promptText, sysPromptText, opts = {}) {
621
624
  let _abort = null;
622
625
  const promise = new Promise((resolve) => {
623
626
  const _startMs = Date.now();
624
- const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, {
627
+ const { proc, cleanupFiles, cleanupDirs } = _spawnProcess(promptText, sysPromptText, {
625
628
  direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
626
629
  maxBudget, bare, fallbackModel,
627
630
  ...runtimeFeatureOpts,
@@ -657,6 +660,7 @@ function callLLM(promptText, sysPromptText, opts = {}) {
657
660
  clearTimeout(timer);
658
661
  if (exitFallbackTimer) { clearTimeout(exitFallbackTimer); exitFallbackTimer = null; }
659
662
  for (const f of cleanupFiles) safeUnlink(f);
663
+ for (const d of (cleanupDirs || [])) shared.removeDispatchTmpDir(d);
660
664
  const parsed = acc.finalize();
661
665
  const durationMs = Date.now() - _startMs;
662
666
  const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
@@ -694,6 +698,7 @@ function callLLM(promptText, sysPromptText, opts = {}) {
694
698
  clearTimeout(timer);
695
699
  if (exitFallbackTimer) { clearTimeout(exitFallbackTimer); exitFallbackTimer = null; }
696
700
  for (const f of cleanupFiles) safeUnlink(f);
701
+ for (const d of (cleanupDirs || [])) shared.removeDispatchTmpDir(d);
697
702
  shared.log('error', `LLM spawn error (${label}): ${err.message}`);
698
703
  resolve({
699
704
  text: '', usage: null, sessionId: null, code: 1,
@@ -733,7 +738,7 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
733
738
  let _abort = null;
734
739
  const promise = new Promise((resolve) => {
735
740
  const _startMs = Date.now();
736
- const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, {
741
+ const { proc, cleanupFiles, cleanupDirs } = _spawnProcess(promptText, sysPromptText, {
737
742
  direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
738
743
  maxBudget, bare, fallbackModel,
739
744
  ...runtimeFeatureOpts,
@@ -772,6 +777,7 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
772
777
  clearTimeout(timer);
773
778
  if (exitFallbackTimer) { clearTimeout(exitFallbackTimer); exitFallbackTimer = null; }
774
779
  for (const f of cleanupFiles) safeUnlink(f);
780
+ for (const d of (cleanupDirs || [])) shared.removeDispatchTmpDir(d);
775
781
  const parsed = acc.finalize();
776
782
  const durationMs = Date.now() - _startMs;
777
783
  const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
@@ -806,6 +812,7 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
806
812
  clearTimeout(timer);
807
813
  if (exitFallbackTimer) { clearTimeout(exitFallbackTimer); exitFallbackTimer = null; }
808
814
  for (const f of cleanupFiles) safeUnlink(f);
815
+ for (const d of (cleanupDirs || [])) shared.removeDispatchTmpDir(d);
809
816
  shared.log('error', `LLM-stream spawn error (${label}): ${err.message}`);
810
817
  resolve({
811
818
  text: '', usage: null, sessionId: null, code: 1,
package/engine/meeting.js CHANGED
@@ -694,6 +694,7 @@ function _killMeetingDispatches(meetingId) {
694
694
  const tmpDir = path.join(shared.MINIONS_DIR, 'engine', 'tmp');
695
695
  const entriesToStop = [];
696
696
  const filesToDelete = [];
697
+ const dispatchDirsToRemove = [];
697
698
  shared.mutateJsonFileLocked(DISPATCH_PATH, (dp) => {
698
699
  dp.pending = Array.isArray(dp.pending) ? dp.pending : [];
699
700
  dp.active = Array.isArray(dp.active) ? dp.active : [];
@@ -707,10 +708,17 @@ function _killMeetingDispatches(meetingId) {
707
708
  continue;
708
709
  }
709
710
  entriesToStop.push(d);
710
- filesToDelete.push(path.join(tmpDir, `pid-${d.id}.pid`));
711
- filesToDelete.push(path.join(tmpDir, `prompt-${d.id}.md`));
712
- filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md`));
713
- filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md.tmp`));
711
+ // P-f6-tmp-toctou: prefer per-dispatch dir cleanup. Fall back to
712
+ // legacy individual-file cleanup for entries that pre-date the
713
+ // per-dispatch-dir layout.
714
+ if (d.tmpDir && shared.validateDispatchTmpDir(d.tmpDir)) {
715
+ dispatchDirsToRemove.push(d.tmpDir);
716
+ } else {
717
+ filesToDelete.push(path.join(tmpDir, `pid-${d.id}.pid`));
718
+ filesToDelete.push(path.join(tmpDir, `prompt-${d.id}.md`));
719
+ filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md`));
720
+ filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md.tmp`));
721
+ }
714
722
  }
715
723
  dp[queue] = kept;
716
724
  }
@@ -725,7 +733,7 @@ function _killMeetingDispatches(meetingId) {
725
733
  const pidsToKill = [];
726
734
  for (const d of entriesToStop) {
727
735
  try {
728
- const pidFile = path.join(tmpDir, `pid-${d.id}.pid`);
736
+ const pidFile = shared.findDispatchPidFile(d) || path.join(tmpDir, `pid-${d.id}.pid`);
729
737
  const pid = shared.validatePid(fs.readFileSync(pidFile, 'utf8').trim());
730
738
  pidsToKill.push(pid);
731
739
  } catch { /* pending entries and already-finished agents may not have PID files */ }
@@ -736,6 +744,9 @@ function _killMeetingDispatches(meetingId) {
736
744
  for (const fp of filesToDelete) {
737
745
  try { fs.unlinkSync(fp); } catch { /* sidecar may not exist */ }
738
746
  }
747
+ for (const dir of dispatchDirsToRemove) {
748
+ shared.removeDispatchTmpDir(dir);
749
+ }
739
750
 
740
751
  if (entriesToStop.length > 0) log('info', `Killed ${entriesToStop.length} meeting dispatch(es) for ${meetingId}`);
741
752
  return entriesToStop.length;