@cspeach/cli 1.1.24 → 1.1.25

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.
@@ -0,0 +1,117 @@
1
+ // cspeach-cli/src/projects/detail-path.ts
2
+ /**
3
+ * SEC (review round 2): `content.detailPath` inside a saved envelope is DATA
4
+ * the model can write — never a trusted path. Every reader that turns it into
5
+ * a file access (upgrade merge baseline hash, the register's CCA objects.json,
6
+ * promote's file_read hint) goes through here:
7
+ *
8
+ * - safeDetailRel: the string must be a plain project-relative path under a
9
+ * skill working folder — `.cspeach/<domain>/…` (or the legacy
10
+ * `.abapforge/<domain>/…`). No absolute paths, drive letters, `..`, `:`
11
+ * streams, control characters, trailing dots/spaces or device names.
12
+ * - resolveDetailFile(Sync): the REAL file must sit in that working folder
13
+ * under the project root (or the workspace folder) and not be a protected
14
+ * path. Returns null for refused AND missing alike, so callers can give ONE
15
+ * message and never act as an existence oracle.
16
+ */
17
+ import * as path from 'node:path';
18
+ import { realpathSync, statSync } from 'node:fs';
19
+ import { CSPEACH_WORK_SUBDIRS, isDenylistedPath, isWindowsReservedName } from '../tools/_filesystem-shared.js';
20
+ import { workspaceFolderFor } from '../lane/roots.js';
21
+ /** Working-folder roots a detail path may start with (current, legacy). */
22
+ const DETAIL_ROOTS = ['.cspeach', '.abapforge'];
23
+ /**
24
+ * The normalised '/'-separated project-relative detail path, or null when the
25
+ * value is not a safe path into `<root>/<domain>/` (any domain when omitted).
26
+ */
27
+ export function safeDetailRel(detailPath, domain) {
28
+ if (typeof detailPath !== 'string' || detailPath.length === 0 || detailPath.length > 512)
29
+ return null;
30
+ if (/[\u0000-\u001f\u007f]/.test(detailPath))
31
+ return null;
32
+ if (path.posix.isAbsolute(detailPath) || path.win32.isAbsolute(detailPath) || /^[A-Za-z]:/.test(detailPath))
33
+ return null;
34
+ const segs = detailPath.replace(/\\/g, '/').split('/');
35
+ while (segs[0] === '.')
36
+ segs.shift();
37
+ if (segs.length < 3)
38
+ return null;
39
+ for (const seg of segs) {
40
+ if (seg === '' || seg === '.' || seg === '..')
41
+ return null;
42
+ if (seg.includes(':') || /[. ]$/.test(seg) || /[<>"|?*]/.test(seg))
43
+ return null;
44
+ if (isWindowsReservedName(seg))
45
+ return null;
46
+ }
47
+ const rootSeg = segs[0].toLowerCase();
48
+ const domainSeg = segs[1].toLowerCase();
49
+ if (!DETAIL_ROOTS.includes(rootSeg))
50
+ return null;
51
+ if (domain ? domainSeg !== domain : !CSPEACH_WORK_SUBDIRS.includes(domainSeg))
52
+ return null;
53
+ return segs.join('/');
54
+ }
55
+ function realOrNull(p) {
56
+ try {
57
+ return realpathSync.native(p);
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /**
64
+ * The real path of the first candidate that exists, is a regular file, stays
65
+ * inside `<base>/<first two segments of rel>/` after links are followed, and
66
+ * is not denylisted relative to `cwd`. null otherwise.
67
+ */
68
+ function firstContained(cands, cwd) {
69
+ for (const { base, rel, keep } of cands) {
70
+ const realBase = realOrNull(path.resolve(base));
71
+ const real = realOrNull(path.join(path.resolve(base), ...rel.split('/')));
72
+ if (!realBase || !real)
73
+ continue;
74
+ const segs = rel.split('/');
75
+ const folder = path.join(realBase, ...segs.slice(0, keep));
76
+ const inner = path.relative(folder, real);
77
+ if (inner === '' || inner.startsWith('..') || path.isAbsolute(inner))
78
+ continue;
79
+ if (isDenylistedPath(real, cwd).blocked)
80
+ continue;
81
+ try {
82
+ if (!statSync(real).isFile())
83
+ continue;
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ return real;
89
+ }
90
+ return null;
91
+ }
92
+ /** Sync resolution under the project root only (callers without a workspace). */
93
+ export function resolveDetailFileSync(detailPath, domain, projectRoot) {
94
+ const rel = safeDetailRel(detailPath, domain);
95
+ if (!rel)
96
+ return null;
97
+ return firstContained([{ base: projectRoot, rel, keep: 2 }], projectRoot);
98
+ }
99
+ /**
100
+ * Resolution under the project root (cwd) and the resolved workspace folder
101
+ * (which may sit elsewhere: a git-root `.cspeach/`, a configured folder). For
102
+ * the workspace, a `.cspeach/<domain>/x` path maps to `<workspace>/<domain>/x`.
103
+ */
104
+ export async function resolveDetailFile(detailPath, domain, cwd) {
105
+ const rel = safeDetailRel(detailPath, domain);
106
+ if (!rel)
107
+ return null;
108
+ const cands = [{ base: cwd, rel, keep: 2 }];
109
+ const segs = rel.split('/');
110
+ if (segs[0].toLowerCase() === '.cspeach') {
111
+ try {
112
+ cands.push({ base: await workspaceFolderFor(cwd), rel: segs.slice(1).join('/'), keep: 1 });
113
+ }
114
+ catch { /* no workspace — project root only */ }
115
+ }
116
+ return firstContained(cands, cwd);
117
+ }
@@ -27,6 +27,7 @@
27
27
  * file must be consulted for full coverage
28
28
  */
29
29
  import { OUTPUT_ROOT } from './output-paths.js';
30
+ import { isWindowsReservedName } from '../tools/_filesystem-shared.js';
30
31
  /** Higher = more restrictive. Conflict resolution picks the max. */
31
32
  const RESTRICTIVENESS = {
32
33
  retire: 3,
@@ -94,6 +95,19 @@ export function scopePatternsIntersect(a, b) {
94
95
  function todayUtc() {
95
96
  return new Date().toISOString().slice(0, 10);
96
97
  }
98
+ /** A folder/file-name slug: `^[A-Za-z0-9._-]+$`, no `..`, not `.`, no device name. */
99
+ export function assertMergeSlug(label, value) {
100
+ const ok = typeof value === 'string'
101
+ && value.length > 0 && value.length <= 100
102
+ && /^[A-Za-z0-9._-]+$/.test(value)
103
+ && !value.includes('..') && value !== '.'
104
+ && !/[. ]$/.test(value)
105
+ && !isWindowsReservedName(value);
106
+ if (!ok) {
107
+ throw new Error(`${label} ${JSON.stringify(value)} cannot name a folder — use letters, digits, ".", "_" and "-" only `
108
+ + '(no "..", no device names such as CON or NUL).');
109
+ }
110
+ }
97
111
  export function mergeCcaAssessments(inputs, opts = {}) {
98
112
  if (inputs.length < 2) {
99
113
  throw new Error(`cca merge requires at least 2 inputs (got ${inputs.length}) — a single assessment needs no merge`);
@@ -214,6 +228,11 @@ export function mergeCcaAssessments(inputs, opts = {}) {
214
228
  // overlap is already warned about above).
215
229
  const uncategorized = inputs.reduce((sum, inp) => sum + (inp.content.summary.uncategorized || 0), 0);
216
230
  const date = opts.mergedDate ?? todayUtc();
231
+ // SEC (review round 2): projectId comes from INSIDE an envelope (model-
232
+ // writable) and becomes a folder name under .cspeach/cca/projects/. Only a
233
+ // plain slug may do that — never `../`, a drive, a stream or a device name.
234
+ assertMergeSlug('projectId', first.content.projectId);
235
+ assertMergeSlug('merged date', date);
217
236
  const mergedProjectId = `${first.content.projectId}_merged_${date}`;
218
237
  const scopes = [];
219
238
  for (const inp of inputs) {
@@ -17,6 +17,22 @@
17
17
  * - counts RECOMPUTED from merged rows — never trusted from inputs
18
18
  */
19
19
  import { OUTPUT_ROOT } from './output-paths.js';
20
+ import { isWindowsReservedName } from '../tools/_filesystem-shared.js';
21
+ /**
22
+ * SEC (review round 2): the merged detail FILE NAME is built from the last
23
+ * segment of the baseline path, which comes from inside an envelope (model-
24
+ * writable). On Windows `base.json:ads` would write an NTFS stream and `CON`
25
+ * opens the console. Keep only `[A-Za-z0-9._-]`, drop a stream suffix, never
26
+ * `..` or a device name; fall back to `baseline`.
27
+ */
28
+ export function safeStem(baselinePath) {
29
+ const last = (typeof baselinePath === 'string' ? baselinePath : '').split(/[\\/]/).pop() ?? '';
30
+ let stem = last.split(':')[0].replace(/\.json$/i, '');
31
+ stem = stem.replace(/[^A-Za-z0-9._-]/g, '_').replace(/\.{2,}/g, '_').replace(/^[._-]+|[._ -]+$/g, '');
32
+ if (!stem || stem.length > 100 || isWindowsReservedName(stem))
33
+ return 'baseline';
34
+ return stem;
35
+ }
20
36
  /** Higher = wins the reconciliation. */
21
37
  const STATUS_PRECEDENCE = {
22
38
  failed: 3,
@@ -124,7 +140,9 @@ export function mergeUpgradeProgress(inputs, baseline) {
124
140
  }
125
141
  const baselinePath = baseline.path ?? inputs[0].content.baselineRef.path;
126
142
  const date = baseline.mergedDate ?? todayUtc();
127
- const stem = (baselinePath.split(/[\\\/]/).pop() ?? 'baseline').replace(/\.json$/i, '');
143
+ const stem = safeStem(baselinePath);
144
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
145
+ throw new Error(`merged date ${JSON.stringify(date)} must be YYYY-MM-DD`);
128
146
  const merged = {
129
147
  detailPath: `${OUTPUT_ROOT}/upgrades/${stem}_progress_merged_${date}.json`,
130
148
  baselineRef: { path: baselinePath, sha256: baseline.sha256 },
@@ -1,4 +1,5 @@
1
1
  import { canonicalSha256 } from './canonicalize.js';
2
+ import { safeDetailRel } from './detail-path.js';
2
3
  function itemTextFor(env, itemId) {
3
4
  if (env.artefactType === 'spec-gap') {
4
5
  for (const s of env.content.sections) {
@@ -105,7 +106,12 @@ export function buildReviewDecisionsBlock(source) {
105
106
  * makes readable — so we hand the skill that exact path to `file_read` directly.
106
107
  */
107
108
  export function buildDetailPathHint(source) {
108
- const detailPath = source.content?.detailPath;
109
+ // SEC (review round 2): detailPath is envelope DATA (model-writable). Only a
110
+ // plain project-relative path into a skill working folder
111
+ // (.cspeach/<cca|upgrades|modernize|tests>/…) is handed to the skill as a
112
+ // file_read target — never an absolute path, `..`, a stream, a protected
113
+ // file, or text carrying a newline into the prompt.
114
+ const detailPath = safeDetailRel(source.content?.detailPath);
109
115
  if (!detailPath)
110
116
  return null;
111
117
  return [
@@ -12,6 +12,7 @@ import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { classificationFromCcaDetail, fromCcaDetailKey, normalizeKey, normalizeRuleId, objectKey, } from '@cspeach/register-core';
14
14
  import { defaultRand, readBaseline } from './store.js';
15
+ import { safeDetailRel, resolveDetailFileSync } from '../projects/detail-path.js';
15
16
  const FIX_STATUSES = new Set(['fixed', 'skipped', 'failed', 'pending']);
16
17
  export function writeRecord(joined, recordType, rows, by, opts = {}) {
17
18
  const known = new Set(readBaseline(joined.projectDir).map((o) => objectKey(o.type, o.name)));
@@ -69,8 +70,12 @@ export function writeRecord(joined, recordType, rows, by, opts = {}) {
69
70
  */
70
71
  export function decisionRowsFromCca(content, projectRoot) {
71
72
  const rows = new Map();
72
- const detail = path.join(projectRoot, path.dirname(content.detailPath ?? ''), 'objects.json');
73
- if (content.detailPath && fs.existsSync(detail)) {
73
+ // SEC (review round 2): detailPath is envelope DATA. objects.json is read only
74
+ // from beside a safe `.cspeach/cca/…` detail path, as a real file inside the
75
+ // project's cca working folder — never from an arbitrary location.
76
+ const rel = safeDetailRel(content.detailPath, 'cca');
77
+ const detail = rel ? resolveDetailFileSync(`${path.posix.dirname(rel)}/objects.json`, 'cca', projectRoot) : null;
78
+ if (detail) {
74
79
  try {
75
80
  const objects = JSON.parse(fs.readFileSync(detail, 'utf8'));
76
81
  for (const [detailKey, o] of Object.entries(objects ?? {})) {
@@ -1,6 +1,6 @@
1
1
  // GENERATED FILE — bundled skills for local mode.
2
2
  // Source: https://manifest.cspeach.dev/v1.json
3
- // Generated: 2026-10-02T01:44:09.559Z
3
+ // Generated: 2026-10-02T14:32:26.255Z
4
4
  // Manifest signature verified at build time.
5
5
  export const BUNDLED_SKILLS = {
6
6
  "abap-app": {