@planu/cli 4.11.6 → 4.11.7

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,3 +1,9 @@
1
+ ## [4.11.7] - 2026-07-18
2
+
3
+ ### Bug Fixes
4
+ - fix: harden shell execution paths
5
+
6
+
1
7
  ## [4.11.6] - 2026-07-18
2
8
 
3
9
  ### Bug Fixes
@@ -1,7 +1,7 @@
1
1
  // Planu — engine/actuals/git-analyzer.ts (SPEC-061)
2
2
  // Derives actuals from git log history for a given spec.
3
- import { execSync } from 'node:child_process';
4
- const SPEC_ID_PATTERN = /^SPEC-\d+$/;
3
+ import { execFileSync } from 'node:child_process';
4
+ import { isCanonicalSpecId } from '../../tools/schemas/spec-id.js';
5
5
  const MAX_SESSION_GAP_MS = 2 * 60 * 60 * 1000; // 2 hours
6
6
  const REVIEW_HOURS_RATIO = 0.15;
7
7
  const MINIMUM_DEV_HOURS = 1;
@@ -121,7 +121,7 @@ export function calculateLinesChanged(commits) {
121
121
  * Returns null if specId is invalid or no commits are found.
122
122
  */
123
123
  export function deriveActualsFromGit(projectPath, specId) {
124
- if (!SPEC_ID_PATTERN.test(specId)) {
124
+ if (!isCanonicalSpecId(specId)) {
125
125
  return Promise.resolve(null);
126
126
  }
127
127
  const gitOutput = runGitLog(projectPath, specId);
@@ -155,7 +155,7 @@ export function deriveActualsFromGit(projectPath, specId) {
155
155
  }
156
156
  function runGitLog(projectPath, specId) {
157
157
  try {
158
- const result = execSync(`git log --format="%H|%aI|%s" --numstat --all --grep="${specId}"`, {
158
+ const result = execFileSync('git', ['log', '--format=%H|%aI|%s', '--numstat', '--all', '--grep', specId], {
159
159
  cwd: projectPath,
160
160
  encoding: 'utf-8',
161
161
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -1,5 +1,6 @@
1
1
  // engine/diff-spec-generator.ts — Diff-to-Spec generator (SPEC-350)
2
- import { execSync } from 'node:child_process';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { assertPositivePrNumber, assertSafeGitDiffRange } from './git-safe-input.js';
3
4
  // ---------------------------------------------------------------------------
4
5
  // Diff analysis
5
6
  // ---------------------------------------------------------------------------
@@ -88,9 +89,12 @@ export function generateSpecDraft(analysis, sourceType, sourceRef, titleOverride
88
89
  // ---------------------------------------------------------------------------
89
90
  export function runGitDiff(repoPath, commitRange) {
90
91
  const range = commitRange ?? 'HEAD~1..HEAD';
91
- const cmd = `git -C "${repoPath}" diff ${range}`;
92
+ assertSafeGitDiffRange(range);
92
93
  try {
93
- return execSync(cmd, { encoding: 'utf-8', timeout: 15_000 });
94
+ return execFileSync('git', ['-C', repoPath, 'diff', range], {
95
+ encoding: 'utf-8',
96
+ timeout: 15_000,
97
+ });
94
98
  }
95
99
  catch (err) {
96
100
  throw new Error(`Failed to run git diff: ${err instanceof Error ? err.message : String(err)}. ` +
@@ -98,9 +102,12 @@ export function runGitDiff(repoPath, commitRange) {
98
102
  }
99
103
  }
100
104
  export function runGhPrDiff(repoPath, prNumber) {
101
- const cmd = `gh pr diff ${prNumber} --repo "${repoPath}"`;
105
+ assertPositivePrNumber(prNumber);
102
106
  try {
103
- return execSync(cmd, { encoding: 'utf-8', timeout: 20_000 });
107
+ return execFileSync('gh', ['pr', 'diff', String(prNumber), '--repo', repoPath], {
108
+ encoding: 'utf-8',
109
+ timeout: 20_000,
110
+ });
104
111
  }
105
112
  catch (err) {
106
113
  throw new Error(`Failed to run gh pr diff: ${err instanceof Error ? err.message : String(err)}. ` +
@@ -0,0 +1,6 @@
1
+ export declare function isSafeGitRef(value: string): boolean;
2
+ export declare function assertSafeGitRef(value: string, label?: string): void;
3
+ export declare function isSafeGitDiffRange(value: string): boolean;
4
+ export declare function assertSafeGitDiffRange(value: string): void;
5
+ export declare function assertPositivePrNumber(value: number): void;
6
+ //# sourceMappingURL=git-safe-input.d.ts.map
@@ -0,0 +1,41 @@
1
+ const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/@{}:+~-]{0,200}$/;
2
+ function hasShellMetacharacters(value) {
3
+ return /[\s`$;&|<>\\\n\r]/.test(value);
4
+ }
5
+ export function isSafeGitRef(value) {
6
+ return (value.length > 0 &&
7
+ SAFE_GIT_REF_PATTERN.test(value) &&
8
+ !value.startsWith('-') &&
9
+ !value.includes('..') &&
10
+ !hasShellMetacharacters(value));
11
+ }
12
+ export function assertSafeGitRef(value, label = 'git ref') {
13
+ if (!isSafeGitRef(value)) {
14
+ throw new Error(`Invalid ${label}. Use a canonical commit hash or git ref without whitespace or shell metacharacters.`);
15
+ }
16
+ }
17
+ export function isSafeGitDiffRange(value) {
18
+ if (value.length === 0 || hasShellMetacharacters(value)) {
19
+ return false;
20
+ }
21
+ if (value.includes('...')) {
22
+ const parts = value.split('...');
23
+ return parts.length === 2 && parts.every((part) => part !== '' && isSafeGitRef(part));
24
+ }
25
+ if (value.includes('..')) {
26
+ const parts = value.split('..');
27
+ return parts.length === 2 && parts.every((part) => part !== '' && isSafeGitRef(part));
28
+ }
29
+ return isSafeGitRef(value);
30
+ }
31
+ export function assertSafeGitDiffRange(value) {
32
+ if (!isSafeGitDiffRange(value)) {
33
+ throw new Error('Invalid commitRange. Use a canonical git ref or a safe range like base..head or base...head.');
34
+ }
35
+ }
36
+ export function assertPositivePrNumber(value) {
37
+ if (!Number.isInteger(value) || value <= 0) {
38
+ throw new Error('Invalid prNumber. Use a positive integer pull request number.');
39
+ }
40
+ }
41
+ //# sourceMappingURL=git-safe-input.js.map
@@ -2,10 +2,12 @@
2
2
  import { listSpecs } from '../storage/spec-store.js';
3
3
  import { hashProjectPath } from '../storage/base-store.js';
4
4
  import { analyzeCodeImpact, checkCodeChangeCompliance, suggestSpecUpdates, runGitDiffImpact, } from '../engine/code-impact-analyzer.js';
5
- import { execSync } from 'node:child_process';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { assertSafeGitRef } from '../engine/git-safe-input.js';
6
7
  function getCommitDiff(commitHash, projectPath) {
8
+ assertSafeGitRef(commitHash, 'commit ref');
7
9
  try {
8
- return execSync(`git show ${commitHash}`, {
10
+ return execFileSync('git', ['show', commitHash], {
9
11
  cwd: projectPath,
10
12
  encoding: 'utf-8',
11
13
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -14,6 +14,7 @@ import { checkApprovalGate } from '../../engine/approval-workflow.js';
14
14
  import * as approvalStore from '../../storage/approval-store.js';
15
15
  import { isLocked, getLock } from '../../storage/spec-lock-store.js';
16
16
  import { runValidateGate, checkDoneGates, checkComplianceGate, checkQaGate, checkApprovedFormatGate, readApprovedValidationReportGate, checkSpecReviewGate, writeSpecReviewArtifact, } from './dod-gates.js';
17
+ import { writeImplementationReviewReport } from '../../engine/validator/validation-report-writer.js';
17
18
  import { checkLifecycleEvidenceTransitionGate } from './evidence-gate.js';
18
19
  import { buildStatusResponse, buildValidateBlockedResponse, buildDryRunResponse, } from './response-builder.js';
19
20
  import { recordDoneMetrics, syncSpecFiles, tryReconcile, recordTerminalTransitionEvent, } from './file-sync.js';
@@ -570,16 +571,6 @@ export async function handleUpdateStatus(params, server) {
570
571
  validateScoreSource = 'validateSpec';
571
572
  }
572
573
  }
573
- if (newStatus === 'done' && !(params.force ?? params.forceStatus ?? false)) {
574
- validationReportGate = await readApprovedValidationReportGate(specId, projectId, false);
575
- if (!validationReportGate.ok) {
576
- return validationReportGate.error;
577
- }
578
- if (validationReportGate.score !== null) {
579
- validateScore = validationReportGate.score;
580
- validateScoreSource = 'validation-report';
581
- }
582
- }
583
574
  if (newStatus === 'done' &&
584
575
  effectiveGatePath &&
585
576
  !shouldSkipStrictLayoutGateForLegacyTestHarness()) {
@@ -627,6 +618,27 @@ export async function handleUpdateStatus(params, server) {
627
618
  lintWarnings: [],
628
619
  testWarnings: [],
629
620
  };
621
+ if (newStatus === 'done' && !(params.force ?? params.forceStatus ?? false)) {
622
+ if (!isDryRun && effectiveGatePath) {
623
+ await writeImplementationReviewReport({
624
+ projectId,
625
+ specId,
626
+ spec,
627
+ projectPath: effectiveGatePath,
628
+ score: validateScore,
629
+ lintPassed: lintWarnings.length === 0,
630
+ conventionRegression: conventionWarnings.length > 0,
631
+ });
632
+ }
633
+ validationReportGate = await readApprovedValidationReportGate(specId, projectId, false);
634
+ if (!validationReportGate.ok) {
635
+ return validationReportGate.error;
636
+ }
637
+ if (validationReportGate.score !== null) {
638
+ validateScore = validationReportGate.score;
639
+ validateScoreSource = 'validation-report';
640
+ }
641
+ }
630
642
  // SPEC-731: dry_run short-circuit — all gates have been evaluated above.
631
643
  // Do NOT call transitionSpec, appendTransitionEvent, or acquire any lock.
632
644
  if (isDryRun) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.11.6",
3
+ "version": "4.11.7",
4
4
  "description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -34,14 +34,14 @@
34
34
  "packageName": "@planu/core"
35
35
  },
36
36
  "optionalDependencies": {
37
- "@planu/core-darwin-arm64": "4.11.6",
38
- "@planu/core-darwin-x64": "4.11.6",
39
- "@planu/core-linux-arm64-gnu": "4.11.6",
40
- "@planu/core-linux-arm64-musl": "4.11.6",
41
- "@planu/core-linux-x64-gnu": "4.11.6",
42
- "@planu/core-linux-x64-musl": "4.11.6",
43
- "@planu/core-win32-arm64-msvc": "4.11.6",
44
- "@planu/core-win32-x64-msvc": "4.11.6"
37
+ "@planu/core-darwin-arm64": "4.11.7",
38
+ "@planu/core-darwin-x64": "4.11.7",
39
+ "@planu/core-linux-arm64-gnu": "4.11.7",
40
+ "@planu/core-linux-arm64-musl": "4.11.7",
41
+ "@planu/core-linux-x64-gnu": "4.11.7",
42
+ "@planu/core-linux-x64-musl": "4.11.7",
43
+ "@planu/core-win32-arm64-msvc": "4.11.7",
44
+ "@planu/core-win32-x64-msvc": "4.11.7"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=24.0.0"
package/planu-native.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dev.planu.native",
3
3
  "displayName": "Planu Native Lightweight Surface",
4
- "version": "4.11.6",
4
+ "version": "4.11.7",
5
5
  "packageName": "@planu/cli",
6
6
  "modes": {
7
7
  "lightweight": {
package/planu-plugin.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dev.planu.cli",
3
3
  "displayName": "Planu — Spec Driven Development",
4
4
  "description": "Manage software specs, estimations, and autonomous SDD workflows. Language-agnostic MCP server for Claude Code.",
5
- "version": "4.11.6",
5
+ "version": "4.11.7",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": [
8
8
  "npx",