mstro-app 0.4.13 → 0.4.16

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 (77) hide show
  1. package/dist/server/cli/headless/claude-invoker-process.d.ts.map +1 -1
  2. package/dist/server/cli/headless/claude-invoker-process.js +5 -1
  3. package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
  4. package/dist/server/services/file-explorer-ops.d.ts +1 -1
  5. package/dist/server/services/file-explorer-ops.d.ts.map +1 -1
  6. package/dist/server/services/file-explorer-ops.js +7 -2
  7. package/dist/server/services/file-explorer-ops.js.map +1 -1
  8. package/dist/server/services/plan/composer.d.ts +1 -1
  9. package/dist/server/services/plan/composer.d.ts.map +1 -1
  10. package/dist/server/services/plan/composer.js +3 -2
  11. package/dist/server/services/plan/composer.js.map +1 -1
  12. package/dist/server/services/plan/executor.d.ts +5 -0
  13. package/dist/server/services/plan/executor.d.ts.map +1 -1
  14. package/dist/server/services/plan/executor.js +32 -1
  15. package/dist/server/services/plan/executor.js.map +1 -1
  16. package/dist/server/services/plan/parser-core.d.ts.map +1 -1
  17. package/dist/server/services/plan/parser-core.js +1 -0
  18. package/dist/server/services/plan/parser-core.js.map +1 -1
  19. package/dist/server/services/plan/review-gate.d.ts +2 -0
  20. package/dist/server/services/plan/review-gate.d.ts.map +1 -1
  21. package/dist/server/services/plan/review-gate.js +25 -3
  22. package/dist/server/services/plan/review-gate.js.map +1 -1
  23. package/dist/server/services/plan/types.d.ts +2 -0
  24. package/dist/server/services/plan/types.d.ts.map +1 -1
  25. package/dist/server/services/sandbox-utils.d.ts +3 -1
  26. package/dist/server/services/sandbox-utils.d.ts.map +1 -1
  27. package/dist/server/services/sandbox-utils.js +6 -3
  28. package/dist/server/services/sandbox-utils.js.map +1 -1
  29. package/dist/server/services/websocket/file-explorer-handlers.js +2 -1
  30. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
  31. package/dist/server/services/websocket/git-log-handlers.d.ts.map +1 -1
  32. package/dist/server/services/websocket/git-log-handlers.js +29 -9
  33. package/dist/server/services/websocket/git-log-handlers.js.map +1 -1
  34. package/dist/server/services/websocket/git-worktree-handlers.js +8 -0
  35. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  36. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  37. package/dist/server/services/websocket/handler.js +5 -3
  38. package/dist/server/services/websocket/handler.js.map +1 -1
  39. package/dist/server/services/websocket/plan-execution-handlers.d.ts.map +1 -1
  40. package/dist/server/services/websocket/plan-execution-handlers.js +4 -1
  41. package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
  42. package/dist/server/services/websocket/plan-helpers.js +1 -1
  43. package/dist/server/services/websocket/plan-helpers.js.map +1 -1
  44. package/dist/server/services/websocket/quality-handlers.d.ts +1 -1
  45. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  46. package/dist/server/services/websocket/quality-handlers.js +67 -14
  47. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  48. package/dist/server/services/websocket/quality-persistence.d.ts +2 -0
  49. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  50. package/dist/server/services/websocket/quality-persistence.js +33 -2
  51. package/dist/server/services/websocket/quality-persistence.js.map +1 -1
  52. package/dist/server/services/websocket/quality-review-agent.d.ts +33 -0
  53. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  54. package/dist/server/services/websocket/quality-review-agent.js +360 -72
  55. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  56. package/dist/server/services/websocket/quality-types.d.ts +3 -0
  57. package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
  58. package/dist/server/services/websocket/quality-types.js.map +1 -1
  59. package/package.json +1 -1
  60. package/server/cli/headless/claude-invoker-process.ts +6 -1
  61. package/server/services/file-explorer-ops.ts +7 -2
  62. package/server/services/plan/composer.ts +3 -1
  63. package/server/services/plan/executor.ts +32 -1
  64. package/server/services/plan/parser-core.ts +1 -0
  65. package/server/services/plan/review-gate.ts +28 -3
  66. package/server/services/plan/types.ts +2 -0
  67. package/server/services/sandbox-utils.ts +7 -3
  68. package/server/services/websocket/file-explorer-handlers.ts +2 -1
  69. package/server/services/websocket/git-log-handlers.ts +30 -9
  70. package/server/services/websocket/git-worktree-handlers.ts +9 -0
  71. package/server/services/websocket/handler.ts +6 -3
  72. package/server/services/websocket/plan-execution-handlers.ts +4 -1
  73. package/server/services/websocket/plan-helpers.ts +1 -1
  74. package/server/services/websocket/quality-handlers.ts +69 -9
  75. package/server/services/websocket/quality-persistence.ts +32 -2
  76. package/server/services/websocket/quality-review-agent.ts +427 -72
  77. package/server/services/websocket/quality-types.ts +3 -0
@@ -108,7 +108,8 @@ export function listDirectory(
108
108
  export function writeFile(
109
109
  filePath: string,
110
110
  content: string,
111
- workingDir: string
111
+ workingDir: string,
112
+ encoding?: 'base64'
112
113
  ): FileOperationResult {
113
114
  if (containsDangerousPatterns(filePath)) {
114
115
  console.error(`[FileService] SECURITY: Dangerous pattern in path: "${filePath}"`)
@@ -133,7 +134,11 @@ export function writeFile(
133
134
  }
134
135
  }
135
136
 
136
- writeFileSync(resolvedPath, content, 'utf-8')
137
+ if (encoding === 'base64') {
138
+ writeFileSync(resolvedPath, Buffer.from(content, 'base64'))
139
+ } else {
140
+ writeFileSync(resolvedPath, content, 'utf-8')
141
+ }
137
142
  return { success: true, path: resolvedPath.replace(`${workingDir}/`, '') }
138
143
  } catch (error: unknown) {
139
144
  console.error('[FileService] Error writing file:', error)
@@ -129,6 +129,7 @@ export async function handlePlanPrompt(
129
129
  userPrompt: string,
130
130
  workingDir: string,
131
131
  boardId?: string,
132
+ sandboxed?: boolean,
132
133
  ): Promise<void> {
133
134
  const pmDir = resolvePmDir(workingDir) ?? defaultPmDir(workingDir);
134
135
  const projectContent = readFileOrEmpty(join(pmDir, 'project.md'));
@@ -237,7 +238,7 @@ Implementation guidance.
237
238
  - Give each child issue clear acceptance criteria and files to modify when possible
238
239
  - Set appropriate priorities (P0-P3) based on the issue's importance within the epic
239
240
 
240
- User request: ${userPrompt}`;
241
+ User request: ${userPrompt}${sandboxed ? `\n\nIMPORTANT: This session has project-scoped access. You MUST NOT read, write, or access any files outside of "${workingDir}" and its subdirectories. All file operations (Read, Write, Edit, Glob, Grep, Bash) must target paths within this directory. Do not use absolute paths that escape this directory. Do not use "../" to access parent directories.` : ''}`;
241
242
 
242
243
  try {
243
244
  ctx.broadcastToAll({
@@ -248,6 +249,7 @@ User request: ${userPrompt}`;
248
249
  const runner = new HeadlessRunner({
249
250
  workingDir,
250
251
  directPrompt: enrichedPrompt,
252
+ sandboxed: sandboxed ?? false,
251
253
  outputCallback: (text: string) => {
252
254
  ctx.send(ws, {
253
255
  type: 'planPromptStreaming',
@@ -69,6 +69,8 @@ export class PlanExecutor extends EventEmitter {
69
69
  private configInstaller: ConfigInstaller;
70
70
  /** Flag to prevent start() from clearing scope set by startBoard/startEpic */
71
71
  private _scopeSetByCall = false;
72
+ /** When true, HeadlessRunner instances run with sanitized env and project-scoped system prompt. */
73
+ private sandboxed = false;
72
74
  private metrics: ExecutionMetrics = {
73
75
  issuesCompleted: 0,
74
76
  issuesAttempted: 0,
@@ -85,6 +87,7 @@ export class PlanExecutor extends EventEmitter {
85
87
 
86
88
  getStatus(): ExecutionStatus { return this.status; }
87
89
  getMetrics(): ExecutionMetrics { return { ...this.metrics }; }
90
+ setSandboxed(value: boolean): void { this.sandboxed = value; }
88
91
 
89
92
  async startEpic(epicPath: string): Promise<void> {
90
93
  this.epicScope = epicPath;
@@ -240,14 +243,19 @@ export class PlanExecutor extends EventEmitter {
240
243
  outputPath,
241
244
  });
242
245
 
246
+ const sandboxPrompt = this.sandboxed
247
+ ? `\n\nIMPORTANT: This session has project-scoped access. You MUST NOT read, write, or access any files outside of "${this.workingDir}" and its subdirectories. All file operations (Read, Write, Edit, Glob, Grep, Bash) must target paths within this directory. Do not use absolute paths that escape this directory. Do not use "../" to access parent directories.`
248
+ : '';
249
+
243
250
  const runner = new HeadlessRunner({
244
251
  workingDir: this.workingDir,
245
- directPrompt: prompt,
252
+ directPrompt: prompt + sandboxPrompt,
246
253
  stallWarningMs: ISSUE_STALL_WARNING_MS,
247
254
  stallKillMs: ISSUE_STALL_KILL_MS,
248
255
  stallHardCapMs: ISSUE_STALL_HARD_CAP_MS,
249
256
  stallMaxExtensions: ISSUE_STALL_MAX_EXTENSIONS,
250
257
  verbose: process.env.MSTRO_VERBOSE === '1',
258
+ sandboxed: this.sandboxed,
251
259
  outputCallback: (text: string) => {
252
260
  this.emit('output', { issueId: issue.id, text });
253
261
  },
@@ -363,6 +371,7 @@ export class PlanExecutor extends EventEmitter {
363
371
  outputPath,
364
372
  onOutput: (text) => this.emit('output', { issueId: issue.id, text }),
365
373
  logDir: this.boardDir ? join(this.boardDir, 'logs') : undefined,
374
+ reviewCriteria: this.getBoardReviewCriteria(),
366
375
  });
367
376
  persistReviewResult(reviewDir, issue, result);
368
377
 
@@ -407,6 +416,28 @@ export class PlanExecutor extends EventEmitter {
407
416
  }
408
417
  }
409
418
 
419
+ /** Read the board's custom review criteria, if set. */
420
+ private getBoardReviewCriteria(): string | undefined {
421
+ const pmDir = this.pmDir;
422
+ if (!pmDir) return undefined;
423
+
424
+ const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
425
+ if (!effectiveBoardId) return undefined;
426
+
427
+ const boardMdPath = join(pmDir, 'boards', effectiveBoardId, 'board.md');
428
+ if (!existsSync(boardMdPath)) return undefined;
429
+
430
+ try {
431
+ const content = readFileSync(boardMdPath, 'utf-8');
432
+ const match = content.match(/^review_criteria:\s*"(.+)"/m);
433
+ if (!match) return undefined;
434
+ const raw = match[1].replace(/\\"/g, '"').replace(/\\n/g, '\n').trim();
435
+ return raw || undefined;
436
+ } catch {
437
+ return undefined;
438
+ }
439
+ }
440
+
410
441
  private pickReadyIssues(): Issue[] {
411
442
  const pmDir = this.pmDir;
412
443
  if (!pmDir) {
@@ -397,6 +397,7 @@ export function parseBoard(content: string, filePath: string): Board {
397
397
  goal: String(fm.goal || sections.get('Goal') || ''),
398
398
  executionSummary,
399
399
  maxParallelAgents: clampParallelAgents(fm.max_parallel_agents),
400
+ reviewCriteria: String(fm.review_criteria || sections.get('Review Criteria') || '').replace(/\\n/g, '\n'),
400
401
  path: filePath,
401
402
  };
402
403
  }
@@ -31,6 +31,8 @@ export interface ReviewIssueOptions {
31
31
  onOutput?: (text: string) => void;
32
32
  /** Board-scoped log directory for execution logs. Falls back to global ~/.mstro/logs/headless/ */
33
33
  logDir?: string;
34
+ /** Custom board-level review criteria — replaces default review instructions when set */
35
+ reviewCriteria?: string;
34
36
  }
35
37
 
36
38
  /**
@@ -38,12 +40,12 @@ export interface ReviewIssueOptions {
38
40
  * Returns auto-pass on infrastructure failures to avoid blocking execution.
39
41
  */
40
42
  export async function reviewIssue(options: ReviewIssueOptions): Promise<ReviewResult> {
41
- const { workingDir, issue, pmDir, outputPath, onOutput, logDir } = options;
43
+ const { workingDir, issue, pmDir, outputPath, onOutput, logDir, reviewCriteria } = options;
42
44
  const isCodeTask = issue.filesToModify.length > 0;
43
45
  const issueType: ReviewResult['issueType'] = isCodeTask ? 'code' : 'non-code';
44
46
 
45
47
  try {
46
- const prompt = buildReviewPrompt(issue, pmDir, outputPath, isCodeTask);
48
+ const prompt = buildReviewPrompt(issue, pmDir, outputPath, isCodeTask, reviewCriteria);
47
49
 
48
50
  const runner = new HeadlessRunner({
49
51
  workingDir,
@@ -162,11 +164,34 @@ export function autoPassResult(issueId: string, issueType: ReviewResult['issueTy
162
164
 
163
165
  // ── Private helpers ─────────────────────────────────────────
164
166
 
165
- function buildReviewPrompt(issue: Issue, pmDir: string, outputPath: string, isCodeTask: boolean): string {
167
+ function buildReviewPrompt(issue: Issue, pmDir: string, outputPath: string, isCodeTask: boolean, reviewCriteria?: string): string {
166
168
  const criteria = issue.acceptanceCriteria
167
169
  .map(c => `- [${c.checked ? 'x' : ' '}] ${c.text}`)
168
170
  .join('\n');
169
171
 
172
+ // When custom review criteria are set, use a generic review prompt
173
+ // that applies the user's criteria instead of assuming code review.
174
+ if (reviewCriteria) {
175
+ return `You are a reviewer. Review the work done for issue ${issue.id}: ${issue.title}.
176
+ ${isCodeTask ? `\n## Files Modified\n${issue.filesToModify.map(f => `- ${f}`).join('\n')}` : `\n## Output File\n${outputPath}\n\n## Issue Spec\n${join(pmDir, issue.path)}`}
177
+
178
+ ## Acceptance Criteria
179
+ ${criteria || 'No specific criteria defined.'}
180
+
181
+ ## Review Criteria
182
+ ${reviewCriteria}
183
+
184
+ ## Instructions
185
+ 1. ${isCodeTask ? 'Read each modified file listed above' : 'Read the output file and issue spec at the paths above'}
186
+ 2. Check if all acceptance criteria are met
187
+ 3. Evaluate against the review criteria above
188
+
189
+ Output EXACTLY one JSON object on its own line (no markdown fencing):
190
+ {"passed": true, "checks": [{"name": "criteria_met", "passed": true, "details": "..."}]}
191
+
192
+ Include checks for: criteria_met, review_criteria.`;
193
+ }
194
+
170
195
  if (isCodeTask) {
171
196
  return `You are a code reviewer. Review the work done for issue ${issue.id}: ${issue.title}.
172
197
 
@@ -121,6 +121,8 @@ export interface Board {
121
121
  executionSummary: BoardExecutionSummary | null;
122
122
  /** Max parallel headless Claude Code instances per execution wave (default: 3) */
123
123
  maxParallelAgents: number;
124
+ /** Custom review criteria instructions — replaces default code-review prompt when set */
125
+ reviewCriteria: string;
124
126
  path: string;
125
127
  }
126
128
 
@@ -58,7 +58,8 @@ const BLOCKED_KEYS = new Set([
58
58
  */
59
59
  export function sanitizeEnvForSandbox(
60
60
  env: NodeJS.ProcessEnv,
61
- workingDir: string
61
+ workingDir: string,
62
+ options?: { overrideHome?: boolean }
62
63
  ): Record<string, string> {
63
64
  const result: Record<string, string> = {};
64
65
 
@@ -69,8 +70,11 @@ export function sanitizeEnvForSandbox(
69
70
  result[key] = value;
70
71
  }
71
72
 
72
- // Override HOME to project directory so `cd ~` stays sandboxed
73
- result.HOME = workingDir;
73
+ // Override HOME to project directory so `cd ~` stays sandboxed (e.g. terminals).
74
+ // Claude Code processes opt out (overrideHome: false) to preserve OAuth auth lookup.
75
+ if (options?.overrideHome !== false) {
76
+ result.HOME = workingDir;
77
+ }
74
78
  // Marker so scripts can detect sandboxed execution
75
79
  result.MSTRO_SANDBOXED = '1';
76
80
 
@@ -132,7 +132,8 @@ function handleListDirectory(ctx: HandlerContext, ws: WSContext, msg: WebSocketM
132
132
  function handleWriteFile(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
133
133
  if (!msg.data?.filePath) throw new Error('File path is required');
134
134
  if (msg.data.content === undefined) throw new Error('Content is required');
135
- const result = writeFile(msg.data.filePath, msg.data.content, workingDir);
135
+ const encoding = msg.data.encoding === 'base64' ? 'base64' as const : undefined;
136
+ const result = writeFile(msg.data.filePath, msg.data.content, workingDir, encoding);
136
137
  sendFileResult(ctx, ws, 'fileWritten', tabId, result);
137
138
  if (result.success) {
138
139
  ctx.broadcastToOthers(ws, {
@@ -119,23 +119,29 @@ export async function handleGitSetDirectory(ctx: HandlerContext, ws: WSContext,
119
119
  return;
120
120
  }
121
121
 
122
- // Security: validate path is within working directory to prevent traversal
122
+ // Security: validate path is within working directory OR is a valid worktree of the repo
123
123
  const resolvedDir = resolve(directory);
124
124
  const resolvedWorkingDir = resolve(workingDir);
125
- if (!resolvedDir.startsWith(`${resolvedWorkingDir}/`) && resolvedDir !== resolvedWorkingDir) {
126
- ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Access denied: path outside project directory' } });
127
- return;
125
+ const isWithinWorkingDir = resolvedDir.startsWith(`${resolvedWorkingDir}/`) || resolvedDir === resolvedWorkingDir;
126
+
127
+ if (!isWithinWorkingDir) {
128
+ // Check if the directory is a known worktree of this repo
129
+ const isWorktree = await isValidWorktreePath(resolvedDir, workingDir);
130
+ if (!isWorktree) {
131
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Access denied: path outside project directory' } });
132
+ return;
133
+ }
128
134
  }
129
135
 
130
- const gitPath = join(directory, '.git');
136
+ const gitPath = join(resolvedDir, '.git');
131
137
  const isValid = existsSync(gitPath);
132
138
 
133
139
  if (isValid) {
134
- ctx.gitDirectories.set(tabId, directory);
140
+ ctx.gitDirectories.set(tabId, resolvedDir);
135
141
  }
136
142
 
137
143
  const response: GitDirectorySetResponse = {
138
- directory,
144
+ directory: resolvedDir,
139
145
  isValid,
140
146
  };
141
147
 
@@ -143,7 +149,22 @@ export async function handleGitSetDirectory(ctx: HandlerContext, ws: WSContext,
143
149
 
144
150
  if (isValid) {
145
151
  const { handleGitStatus } = await import('./git-handlers.js');
146
- handleGitStatus(ctx, ws, tabId, directory);
147
- handleGitLog(ctx, ws, { type: 'gitLog', data: { limit: 5 } }, tabId, directory);
152
+ handleGitStatus(ctx, ws, tabId, resolvedDir);
153
+ handleGitLog(ctx, ws, { type: 'gitLog', data: { limit: 5 } }, tabId, resolvedDir);
154
+ }
155
+ }
156
+
157
+ /** Check if a path is a registered worktree of the repo at workingDir */
158
+ async function isValidWorktreePath(targetPath: string, workingDir: string): Promise<boolean> {
159
+ const result = await executeGitCommand(['worktree', 'list', '--porcelain'], workingDir);
160
+ if (result.exitCode !== 0) return false;
161
+
162
+ const resolvedTarget = resolve(targetPath);
163
+ for (const line of result.stdout.split('\n')) {
164
+ if (line.startsWith('worktree ')) {
165
+ const wtPath = resolve(line.slice(9).trim());
166
+ if (wtPath === resolvedTarget) return true;
167
+ }
148
168
  }
169
+ return false;
149
170
  }
@@ -156,6 +156,15 @@ async function handleGitWorktreeRemove(ctx: HandlerContext, ws: WSContext, msg:
156
156
 
157
157
  await executeGitCommand(['worktree', 'prune'], workingDir);
158
158
 
159
+ // Clean up gitDirectories entries for any tabs referencing the removed worktree
160
+ const resolvedWtPath = join(wtPath); // normalize
161
+ for (const [tid, dir] of ctx.gitDirectories) {
162
+ if (dir === resolvedWtPath || dir === wtPath) {
163
+ ctx.gitDirectories.delete(tid);
164
+ ctx.gitBranches.delete(tid);
165
+ }
166
+ }
167
+
159
168
  ctx.send(ws, { type: 'gitWorktreeRemoved', tabId, data: { path: wtPath } });
160
169
  } catch (error: unknown) {
161
170
  ctx.send(ws, { type: 'gitError', tabId, data: { error: error instanceof Error ? error.message : String(error) } });
@@ -178,14 +178,17 @@ export class WebSocketImproviseHandler implements HandlerContext {
178
178
  const domain = WebSocketImproviseHandler.DISPATCH[msg.type];
179
179
  if (!domain) throw new Error(`Unknown message type: ${msg.type}`);
180
180
 
181
+ // Resolve effective working directory: use worktree path if tab is on a worktree
182
+ const effectiveDir = this.gitDirectories.get(tabId) || workingDir;
183
+
181
184
  switch (domain) {
182
185
  case 'session': return handleSessionMessage(this, ws, msg, tabId, permission);
183
186
  case 'history': return handleHistoryMessage(this, ws, msg, tabId, workingDir);
184
- case 'file': return handleFileMessage(this, ws, msg, tabId, workingDir, permission);
187
+ case 'file': return handleFileMessage(this, ws, msg, tabId, effectiveDir, permission);
185
188
  case 'terminal': return handleTerminalMessage(this, ws, msg, tabId, workingDir, permission);
186
- case 'fileExplorer': return handleFileExplorerMessage(this, ws, msg, tabId, workingDir, permission);
189
+ case 'fileExplorer': return handleFileExplorerMessage(this, ws, msg, tabId, effectiveDir, permission);
187
190
  case 'git': return handleGitMessage(this, ws, msg, tabId, workingDir);
188
- case 'quality': return handleQualityMessage(this, ws, msg, tabId, workingDir);
191
+ case 'quality': return handleQualityMessage(this, ws, msg, tabId, workingDir, permission);
189
192
  case 'plan': return handlePlanMessage(this, ws, msg, tabId, workingDir, permission);
190
193
  case 'fileUpload': return this.handleFileUploadMessage(ws, msg, tabId, workingDir);
191
194
  }
@@ -24,7 +24,8 @@ export function handlePrompt(
24
24
  ctx.send(ws, { type: 'planError', data: { error: 'Prompt required' } });
25
25
  return;
26
26
  }
27
- handlePlanPrompt(ctx, ws, prompt, workingDir, boardId).catch(error => {
27
+ const sandboxed = permission === 'control';
28
+ handlePlanPrompt(ctx, ws, prompt, workingDir, boardId, sandboxed).catch(error => {
28
29
  ctx.send(ws, {
29
30
  type: 'planError',
30
31
  data: { error: error instanceof Error ? error.message : String(error) },
@@ -100,6 +101,7 @@ export function handleExecute(
100
101
  if (denyIfViewOnly(ctx, ws, permission)) return;
101
102
 
102
103
  const executor = getExecutor(workingDir);
104
+ executor.setSandboxed(permission === 'control');
103
105
 
104
106
  if (executor.getStatus() === 'executing' || executor.getStatus() === 'starting') {
105
107
  ctx.send(ws, { type: 'planError', data: { error: 'Execution already in progress' } });
@@ -132,6 +134,7 @@ export function handleExecuteEpic(
132
134
  }
133
135
 
134
136
  const executor = getExecutor(workingDir);
137
+ executor.setSandboxed(permission === 'control');
135
138
 
136
139
  if (executor.getStatus() === 'executing' || executor.getStatus() === 'starting') {
137
140
  ctx.send(ws, { type: 'planError', data: { error: 'Execution already in progress' } });
@@ -39,7 +39,7 @@ export function formatYamlValue(value: unknown): string {
39
39
  if (value.length === 0) return '[]';
40
40
  return `[${value.map(v => typeof v === 'string' ? v : String(v)).join(', ')}]`;
41
41
  }
42
- return `"${String(value).replace(/"/g, '\\"')}"`;
42
+ return `"${String(value).replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
43
43
  }
44
44
 
45
45
  export function buildIssueMarkdown(
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { join } from 'node:path';
14
+ import { validatePathWithinWorkingDir } from '../pathUtils.js';
14
15
  import type { HandlerContext } from './handler-context.js';
15
16
  import type { FindingForFix } from './quality-fix-agent.js';
16
17
  import { handleFixIssues } from './quality-fix-agent.js';
@@ -39,6 +40,26 @@ function resolvePath(workingDir: string, dirPath?: string): string {
39
40
  return join(workingDir, dirPath);
40
41
  }
41
42
 
43
+ /**
44
+ * Resolve and validate a directory path for sandboxed users.
45
+ * Returns null if the path escapes the working directory.
46
+ */
47
+ function resolveAndValidatePath(
48
+ workingDir: string,
49
+ dirPath: string | undefined,
50
+ isSandboxed: boolean,
51
+ ): { resolved: string; error?: string } {
52
+ const resolved = resolvePath(workingDir, dirPath);
53
+ if (isSandboxed) {
54
+ const validation = validatePathWithinWorkingDir(resolved, workingDir);
55
+ if (!validation.valid) {
56
+ return { resolved: '', error: validation.error || 'Path outside project directory' };
57
+ }
58
+ return { resolved: validation.resolvedPath };
59
+ }
60
+ return { resolved };
61
+ }
62
+
42
63
  // ── Message router ────────────────────────────────────────────
43
64
 
44
65
  export function handleQualityMessage(
@@ -47,25 +68,33 @@ export function handleQualityMessage(
47
68
  msg: WebSocketMessage,
48
69
  _tabId: string,
49
70
  workingDir: string,
71
+ permission?: 'control' | 'view',
50
72
  ): void {
73
+ const isSandboxed = permission === 'control' || permission === 'view';
74
+ const sendPathError = (path: string, error: string) => {
75
+ ctx.send(ws, { type: 'qualityError', data: { path, error } });
76
+ };
77
+
51
78
  const handlers: Record<string, () => void> = {
52
- qualityDetectTools: () => handleDetectTools(ctx, ws, msg, workingDir),
53
- qualityScan: () => handleScan(ctx, ws, msg, workingDir),
54
- qualityInstallTools: () => handleInstallTools(ctx, ws, msg, workingDir),
79
+ qualityDetectTools: () => handleDetectTools(ctx, ws, msg, workingDir, isSandboxed),
80
+ qualityScan: () => handleScan(ctx, ws, msg, workingDir, isSandboxed),
81
+ qualityInstallTools: () => handleInstallTools(ctx, ws, msg, workingDir, isSandboxed),
55
82
  qualityCodeReview: () => {
56
- const dirPath = resolvePath(workingDir, msg.data?.path);
83
+ const { resolved: dirPath, error } = resolveAndValidatePath(workingDir, msg.data?.path, isSandboxed);
84
+ if (error) { sendPathError(msg.data?.path || '.', error); return; }
57
85
  const reportPath = msg.data?.path || '.';
58
86
  handleCodeReview(ctx, ws, reportPath, dirPath, workingDir, activeReviews, getPersistence);
59
87
  },
60
88
  qualityFixIssues: () => {
61
- const dirPath = resolvePath(workingDir, msg.data?.path);
89
+ const { resolved: dirPath, error } = resolveAndValidatePath(workingDir, msg.data?.path, isSandboxed);
90
+ if (error) { sendPathError(msg.data?.path || '.', error); return; }
62
91
  const reportPath = msg.data?.path || '.';
63
92
  const section: string | undefined = msg.data?.section;
64
93
  const findings: FindingForFix[] = msg.data?.findings || [];
65
94
  handleFixIssues(ctx, ws, reportPath, dirPath, workingDir, section, findings, getPersistence);
66
95
  },
67
96
  qualityLoadState: () => handleLoadState(ctx, ws, workingDir),
68
- qualitySaveDirectories: () => handleSaveDirectories(ctx, ws, msg, workingDir),
97
+ qualitySaveDirectories: () => handleSaveDirectories(ctx, ws, msg, workingDir, isSandboxed),
69
98
  };
70
99
 
71
100
  const handler = handlers[msg.type];
@@ -106,10 +135,26 @@ async function handleSaveDirectories(
106
135
  ws: WSContext,
107
136
  msg: WebSocketMessage,
108
137
  workingDir: string,
138
+ isSandboxed = false,
109
139
  ): Promise<void> {
110
140
  try {
111
141
  const persistence = getPersistence(workingDir);
112
142
  const directories: Array<{ path: string; label: string }> = msg.data?.directories || [];
143
+
144
+ // Validate all directory paths when sandboxed
145
+ if (isSandboxed) {
146
+ for (const dir of directories) {
147
+ const { error } = resolveAndValidatePath(workingDir, dir.path, true);
148
+ if (error) {
149
+ ctx.send(ws, {
150
+ type: 'qualityError',
151
+ data: { path: dir.path, error: `Cannot save directory: ${error}` },
152
+ });
153
+ return;
154
+ }
155
+ }
156
+ }
157
+
113
158
  persistence.saveConfig(directories);
114
159
  } catch (error) {
115
160
  ctx.send(ws, {
@@ -124,8 +169,13 @@ async function handleDetectTools(
124
169
  ws: WSContext,
125
170
  msg: WebSocketMessage,
126
171
  workingDir: string,
172
+ isSandboxed = false,
127
173
  ): Promise<void> {
128
- const dirPath = resolvePath(workingDir, msg.data?.path);
174
+ const { resolved: dirPath, error: pathError } = resolveAndValidatePath(workingDir, msg.data?.path, isSandboxed);
175
+ if (pathError) {
176
+ ctx.send(ws, { type: 'qualityError', data: { path: msg.data?.path || '.', error: pathError } });
177
+ return;
178
+ }
129
179
  try {
130
180
  const { tools, ecosystem } = await detectTools(dirPath);
131
181
  ctx.send(ws, {
@@ -145,8 +195,13 @@ async function handleScan(
145
195
  ws: WSContext,
146
196
  msg: WebSocketMessage,
147
197
  workingDir: string,
198
+ isSandboxed = false,
148
199
  ): Promise<void> {
149
- const dirPath = resolvePath(workingDir, msg.data?.path);
200
+ const { resolved: dirPath, error: pathError } = resolveAndValidatePath(workingDir, msg.data?.path, isSandboxed);
201
+ if (pathError) {
202
+ ctx.send(ws, { type: 'qualityError', data: { path: msg.data?.path || '.', error: pathError } });
203
+ return;
204
+ }
150
205
  const reportPath = msg.data?.path || '.';
151
206
 
152
207
  try {
@@ -184,8 +239,13 @@ async function handleInstallTools(
184
239
  ws: WSContext,
185
240
  msg: WebSocketMessage,
186
241
  workingDir: string,
242
+ isSandboxed = false,
187
243
  ): Promise<void> {
188
- const dirPath = resolvePath(workingDir, msg.data?.path);
244
+ const { resolved: dirPath, error: pathError } = resolveAndValidatePath(workingDir, msg.data?.path, isSandboxed);
245
+ if (pathError) {
246
+ ctx.send(ws, { type: 'qualityError', data: { path: msg.data?.path || '.', error: pathError } });
247
+ return;
248
+ }
189
249
  const reportPath = msg.data?.path || '.';
190
250
  const toolNames: string[] | undefined = msg.data?.tools;
191
251
 
@@ -11,7 +11,7 @@
11
11
  * .mstro/quality/history.json — Score history entries for trend tracking
12
12
  */
13
13
 
14
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
15
15
  import { join } from 'node:path';
16
16
  import type { QualityResults } from './quality-service.js';
17
17
 
@@ -56,6 +56,7 @@ export interface QualityPersistedState {
56
56
  // ============================================================================
57
57
 
58
58
  const MAX_HISTORY_ENTRIES = 100;
59
+ const MAX_REPORT_HISTORY_FILES = 200;
59
60
 
60
61
  function slugify(dirPath: string): string {
61
62
  if (dirPath === '.' || dirPath === './') return '_root';
@@ -94,15 +95,18 @@ function writeJson(filePath: string, data: unknown): void {
94
95
  export class QualityPersistence {
95
96
  private qualityDir: string;
96
97
  private reportsDir: string;
98
+ private reportHistoryDir: string;
97
99
  private configPath: string;
98
100
  private historyPath: string;
99
101
 
100
102
  constructor(workingDir: string) {
101
103
  this.qualityDir = join(workingDir, '.mstro', 'quality');
102
104
  this.reportsDir = join(this.qualityDir, 'reports');
105
+ this.reportHistoryDir = join(this.reportsDir, 'history');
103
106
  this.configPath = join(this.qualityDir, 'config.json');
104
107
  this.historyPath = join(this.qualityDir, 'history.json');
105
108
  ensureDir(this.reportsDir);
109
+ ensureDir(this.reportHistoryDir);
106
110
  }
107
111
 
108
112
  // ---- Config (directory list) ----
@@ -141,6 +145,12 @@ export class QualityPersistence {
141
145
  const slug = slugify(dirPath);
142
146
  const reportPath = join(this.reportsDir, `${slug}.json`);
143
147
  writeJson(reportPath, results);
148
+
149
+ // Archive timestamped copy for historical tracking
150
+ const ts = Date.now();
151
+ const archivePath = join(this.reportHistoryDir, `${ts}_${slug}.json`);
152
+ writeJson(archivePath, results);
153
+ this.pruneReportHistory();
144
154
  }
145
155
 
146
156
  loadAllReports(directories: QualityDirectoryConfig[]): Record<string, QualityResults> {
@@ -154,6 +164,19 @@ export class QualityPersistence {
154
164
  return reports;
155
165
  }
156
166
 
167
+ // ---- Report history pruning ----
168
+
169
+ private pruneReportHistory(): void {
170
+ try {
171
+ const files = readdirSync(this.reportHistoryDir).filter((f) => f.endsWith('.json')).sort();
172
+ if (files.length <= MAX_REPORT_HISTORY_FILES) return;
173
+ const toRemove = files.slice(0, files.length - MAX_REPORT_HISTORY_FILES);
174
+ for (const file of toRemove) {
175
+ try { unlinkSync(join(this.reportHistoryDir, file)); } catch { /* ignore */ }
176
+ }
177
+ } catch { /* directory may not exist yet */ }
178
+ }
179
+
157
180
  // ---- History (trend tracking) ----
158
181
 
159
182
  loadHistory(): QualityHistoryEntry[] {
@@ -219,7 +242,14 @@ export class QualityPersistence {
219
242
  saveCodeReview(dirPath: string, findings: Record<string, unknown>[], summary: string): void {
220
243
  const slug = slugify(dirPath);
221
244
  const reviewPath = join(this.reportsDir, `${slug}-review.json`);
222
- writeJson(reviewPath, { findings, summary, timestamp: new Date().toISOString() });
245
+ const data = { findings, summary, timestamp: new Date().toISOString() };
246
+ writeJson(reviewPath, data);
247
+
248
+ // Archive timestamped copy for historical tracking
249
+ const ts = Date.now();
250
+ const archivePath = join(this.reportHistoryDir, `${ts}_${slug}-review.json`);
251
+ writeJson(archivePath, data);
252
+ this.pruneReportHistory();
223
253
  }
224
254
 
225
255
  // ---- Full state load ----