@cspeach/cli 1.1.23 → 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.
Files changed (51) hide show
  1. package/dist/agent/loop.js +124 -0
  2. package/dist/classifier/client.js +27 -2
  3. package/dist/commands/plan-continue.js +11 -1
  4. package/dist/commands/plan-resume.js +38 -1
  5. package/dist/lane/app-chain.js +297 -0
  6. package/dist/lane/app-flow.js +55 -0
  7. package/dist/lane/card.js +148 -0
  8. package/dist/lane/chain-token.js +59 -0
  9. package/dist/lane/inputs.js +459 -0
  10. package/dist/lane/paths.js +21 -0
  11. package/dist/lane/resolve.js +109 -0
  12. package/dist/lane/roots.js +113 -0
  13. package/dist/lane/signals.js +80 -0
  14. package/dist/lane/store.js +232 -0
  15. package/dist/lane/types.js +1 -0
  16. package/dist/one-shot.js +1 -0
  17. package/dist/projects/detail-path.js +117 -0
  18. package/dist/projects/extract-plan.js +46 -6
  19. package/dist/projects/merge-cca.js +19 -0
  20. package/dist/projects/merge-upgrade.js +19 -1
  21. package/dist/projects/plan-lane.js +214 -0
  22. package/dist/projects/plan-schema.js +10 -0
  23. package/dist/projects/promote-command.js +8 -2
  24. package/dist/projects/promote.js +7 -1
  25. package/dist/projects/save-command.js +71 -25
  26. package/dist/projects/workspace.js +10 -0
  27. package/dist/register/records.js +7 -2
  28. package/dist/renderer/tool-labels.js +2 -1
  29. package/dist/repl/resume-standalone.js +3 -0
  30. package/dist/repl.js +23 -2
  31. package/dist/skill-catalog.js +14 -6
  32. package/dist/skills/bundled-skills.js +61 -54
  33. package/dist/tools/_filesystem-shared.js +363 -33
  34. package/dist/tools/cap/metadata-import.js +10 -4
  35. package/dist/tools/filesystem/file-write.js +12 -39
  36. package/dist/tools/filesystem/glob.js +25 -2
  37. package/dist/tools/filesystem/grep.js +25 -2
  38. package/dist/tools/fiori/apply.js +30 -21
  39. package/dist/tools/fiori/commit-gate.js +58 -0
  40. package/dist/tools/fiori/fe-extend.js +6 -1
  41. package/dist/tools/fiori/fe-scaffold.js +6 -1
  42. package/dist/tools/fiori/path-params.js +78 -0
  43. package/dist/tools/fiori/scaffold.js +2 -1
  44. package/dist/tools/fiori/tools.js +53 -14
  45. package/dist/tools/lane/app-lane-resolve.js +329 -0
  46. package/dist/tools/local-build.js +4 -0
  47. package/dist/tools/project/_merge-shared.js +36 -1
  48. package/dist/tools/project/cca_merge.js +4 -0
  49. package/dist/tools/project/upgrade_merge_progress.js +21 -10
  50. package/dist/tools/tool-loading.js +2 -0
  51. package/package.json +1 -1
@@ -0,0 +1,329 @@
1
+ // cspeach-cli/src/tools/lane/app-lane-resolve.ts
2
+ //
3
+ // App lane routing, slice 1, Task 5 — `app_lane_resolve`. The model fills the
4
+ // signal sheet (values + evidence quotes); everything else is CLI code:
5
+ //
6
+ // sheet check → spec text + quote strength → system facts, pin, standards
7
+ // → resolveLane → lane card (or the headless STOP) → runtime card when a CAP
8
+ // lane has no runtime → sha-pinned decision file → app-flow state → the CLI
9
+ // queues `/abap-plan --from @<specGap>` through the harness slot.
10
+ //
11
+ // Security posture:
12
+ // - The sheet is validated with the strict schema before anything else runs;
13
+ // errors name only the bad keys (sanitised), never values.
14
+ // - The spec path goes through readSpecText (string + realpath containment,
15
+ // denylist, size caps). Errors name the path only, never spec content.
16
+ // - Card strings come from card.ts (sanitised, capped). STOP / summary text
17
+ // passes every model-influenced line through cardLine.
18
+ // - The decision file is written only by store.ts (validated, never overwrites).
19
+ // - The runtime answer is written only by writeRuntimeAnswer, from a card value.
20
+ // - pendingDispatch gets a CLI-built string only: a fixed prefix plus the
21
+ // session's spec-gap path, through the shared chainFromToken (lane/chain-token.ts).
22
+ import { promises as fs } from 'node:fs';
23
+ import * as path from 'node:path';
24
+ import { registerTool, isStandalone } from '../index.js';
25
+ import { PathOutsideRootError } from '../_filesystem-shared.js';
26
+ import { assertNotCliHome, containSpecPath, laneRootFor, specDisplayPath } from '../../lane/roots.js';
27
+ import { signalSheetSchema, withStrength } from '../../lane/signals.js';
28
+ import { factsFromStandalone, factsFromSystemInfo, findQuote, readLanePin, readSpecText, readStandardsLane, writeRuntimeAnswer, } from '../../lane/inputs.js';
29
+ import { BUILT_LANES, resolveLane, uiFor } from '../../lane/resolve.js';
30
+ import { writeLaneDecision } from '../../lane/store.js';
31
+ import { getAppFlow, setLane } from '../../lane/app-flow.js';
32
+ import { chainFromToken } from '../../lane/chain-token.js';
33
+ import { cardLine, interactiveRunLine, laneCardRequest, quoteNotFoundFacts, runtimeCardRequest, shownToken, } from '../../lane/card.js';
34
+ import { cardSlotEmitter } from '../../ui/card-slot.js';
35
+ import { askConfirm } from '../../ui/confirm-request.js';
36
+ import { isHeadless } from '../../renderer/tty.js';
37
+ import { loadConfig } from '../../config/loader.js';
38
+ import { getSapSystemInfo } from '../../sap/system-info.js';
39
+ const fail = (message) => ({ content: `error: ${message}`, is_error: true });
40
+ const NOT_CHOSEN = 'Lane not chosen. Nothing was saved.';
41
+ const STOP = 'STOP — lane needs your choice.';
42
+ const UNKNOWN_FACTS = { source: 'unknown', tier: 'unknown', label: 'unknown' };
43
+ const RUNTIMES = ['btp', 'own-server', 'none'];
44
+ /** A sheet key for an error line: identifier characters only, capped. */
45
+ const keyName = (k) => String(k).replace(/[^A-Za-z0-9_]/g, '').slice(0, 40) || '(unnamed)';
46
+ function sheetError(issues) {
47
+ const bad = new Set();
48
+ let notObject = false;
49
+ for (const i of issues) {
50
+ if (i.code === 'unrecognized_keys' && i.path.length === 0) {
51
+ for (const k of i.keys ?? [])
52
+ bad.add(`${keyName(k)} (unknown key)`);
53
+ }
54
+ else if (i.path.length === 0) {
55
+ notObject = true;
56
+ }
57
+ else {
58
+ bad.add(keyName(i.path[0]));
59
+ }
60
+ }
61
+ if (notObject && bad.size === 0)
62
+ return 'signals must be an object with one entry per signal key.';
63
+ return `the signal sheet is not valid. Fix these keys and call again: ${[...bad].join(', ')}.`;
64
+ }
65
+ async function defaultFacts(ctx) {
66
+ try {
67
+ if (isStandalone(ctx)) {
68
+ return factsFromStandalone((await loadConfig()).standalone);
69
+ }
70
+ return factsFromSystemInfo(await getSapSystemInfo(ctx.adt, ctx.sapAlias));
71
+ }
72
+ catch {
73
+ return { ...UNKNOWN_FACTS };
74
+ }
75
+ }
76
+ /** Headless, or no Ink card slot (classic REPL / one-shot): a card could never be answered. */
77
+ function defaultHeadless() {
78
+ return isHeadless() || cardSlotEmitter.listenerCount('confirm') === 0;
79
+ }
80
+ /** M1 — a person is at the terminal but no card can be shown (the classic REPL). */
81
+ function defaultNoCardTerminal() {
82
+ return !isHeadless() && cardSlotEmitter.listenerCount('confirm') === 0;
83
+ }
84
+ /**
85
+ * True when the app flow's `@spec` token and the given absolute path are the
86
+ * same real file inside cwd or the workspace folder (I3: the same containment
87
+ * as readSpecText).
88
+ */
89
+ async function sameFile(cwd, token, abs) {
90
+ const tok = token.trim().replace(/^@/, '');
91
+ if (tok === '')
92
+ return false;
93
+ try {
94
+ const { real: a } = await containSpecPath(cwd, tok);
95
+ const b = await fs.realpath(abs);
96
+ const norm = (p) => (process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p));
97
+ return norm(a) === norm(b);
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ const LANE_WORDS = {
104
+ rap: 'on-stack RAP', cap: 'CAP (side-by-side)', none: 'UI only', 'abap-report': 'classic report', segw: 'SEGW',
105
+ };
106
+ function stopText(d, extra, specLine) {
107
+ const lines = [STOP, specLine];
108
+ if (d.needsRuntime && !d.neutral && !d.noAppLane) {
109
+ lines.push(`Recommended lane: ${LANE_WORDS.cap}. The CAP lane needs to know where the app will run.`);
110
+ }
111
+ else if (d.backend && !d.noAppLane) {
112
+ lines.push(`Recommended lane: ${LANE_WORDS[d.backend]}.`);
113
+ }
114
+ else {
115
+ lines.push('No app lane is available on this system.');
116
+ }
117
+ lines.push('Why:');
118
+ for (const r of [...d.reasons, ...extra])
119
+ lines.push(`- ${cardLine(r)}`);
120
+ if (d.needsRuntime && !d.neutral && !d.noAppLane) {
121
+ lines.push('To confirm: add `[app-lane] runtime = "btp|own-server|none"` to .cspeach/conventions.toml and run /abap-app again.');
122
+ }
123
+ else if (d.backend && !d.noAppLane) {
124
+ lines.push(`To confirm: Add \`[app-lane] backend = "${d.backend}"\` to .cspeach/conventions.toml and run /abap-app again.`);
125
+ }
126
+ else {
127
+ const choices = d.allowed.filter((l) => l !== 'segw').join(', ');
128
+ lines.push(`To choose: add \`[app-lane] backend = "<lane>"\` (one of: ${choices}) to .cspeach/conventions.toml and run /abap-app again.`);
129
+ }
130
+ lines.push('Nothing was saved.');
131
+ return lines.join('\n');
132
+ }
133
+ export async function appLaneResolveHandler(args, ctx, deps = {}) {
134
+ // 0. Task 7 review round 2 — inside an /abap-app flow the lane is chosen
135
+ // only at the lane step (after the spec-gap save, before the plan). Nothing
136
+ // else runs: no card, no file, the flow and the queue stay as they are.
137
+ // No flow at all → today's behaviour.
138
+ const step = getAppFlow(ctx.session)?.expect;
139
+ if (step !== undefined && step !== 'lane') {
140
+ return fail(step === 'spec-gap'
141
+ ? 'the app flow is not at the lane step yet: save the spec-gap first.'
142
+ : 'the app flow is not at the lane step: the lane is already chosen and the plan is next.');
143
+ }
144
+ // 1. The sheet, strictly, before anything else.
145
+ const parsed = signalSheetSchema.safeParse(args?.signals);
146
+ if (!parsed.success)
147
+ return fail(sheetError(parsed.error.issues));
148
+ if (typeof args?.specPath !== 'string' || args.specPath.trim() === '') {
149
+ return fail('specPath must be the spec file path, relative to the workspace.');
150
+ }
151
+ const specPath = args.specPath;
152
+ // 2. Spec text (contained) and signal strength.
153
+ let spec;
154
+ try {
155
+ spec = await readSpecText(ctx.cwd, specPath);
156
+ }
157
+ catch (e) {
158
+ if (e instanceof PathOutsideRootError)
159
+ return fail('the spec path must be inside the workspace.');
160
+ return fail(cardLine(e instanceof Error ? e.message : 'the spec file cannot be read.'));
161
+ }
162
+ const specSha = spec.sha256;
163
+ const specRel = specDisplayPath(ctx.cwd, spec.abs);
164
+ // The lane check must use the spec /abap-app was started with: a model may
165
+ // not match its quotes against some other workspace file (one it just wrote).
166
+ const flow = getAppFlow(ctx.session);
167
+ if (flow && !(await sameFile(ctx.cwd, flow.specToken, spec.abs))) {
168
+ return fail(cardLine(`This lane check must use the spec you started /abap-app with: ${flow.specToken}`));
169
+ }
170
+ const specLine = cardLine(`Spec: ${specRel}`);
171
+ const signals = withStrength(parsed.data, spec.text, findQuote, spec.normalise);
172
+ // 3–4. Facts, pin, standards.
173
+ const facts = await (deps.facts ?? defaultFacts)(ctx).catch(() => ({ ...UNKNOWN_FACTS }));
174
+ // R18 — the project pin lives on the lane root, next to .cspeach/lane.
175
+ const laneRoot = await laneRootFor(ctx.cwd);
176
+ // Final residual 2 — a lane root that is the CLI home is refused before any read or card.
177
+ try {
178
+ await assertNotCliHome(laneRoot);
179
+ }
180
+ catch (e) {
181
+ return fail(cardLine(e instanceof Error ? e.message : String(e)));
182
+ }
183
+ const [pinR, stdR] = await Promise.all([readLanePin(laneRoot), readStandardsLane(ctx.cwd)]);
184
+ const warnings = [pinR.warning, stdR.warning].filter((w) => !!w);
185
+ const runtimeKnown = !!pinR.pin?.runtime || !!stdR.pin?.runtime;
186
+ // 5. Resolve.
187
+ const decision = resolveLane({ signals, facts, pin: pinR.pin, standards: stdR.pin, runtimeKnown });
188
+ const extraFacts = [...quoteNotFoundFacts(signals), ...warnings.map(cardLine)];
189
+ const cardFacts = [specLine, ...extraFacts];
190
+ let finalLane;
191
+ let decidedBy = decision.decidedBy;
192
+ let runtime = pinR.pin?.runtime ?? stdR.pin?.runtime;
193
+ const headless = (deps.headless ?? defaultHeadless)();
194
+ if (headless) {
195
+ // 6. Headless: never a card. Choices that need a person stop here.
196
+ if (decision.neutral || decision.noAppLane || decision.needsRuntime || !decision.backend) {
197
+ return { content: stopText(decision, extraFacts, specLine) };
198
+ }
199
+ finalLane = { backend: decision.backend, ui: decision.ui, ...(decision.floorplan ? { floorplan: decision.floorplan } : {}) };
200
+ }
201
+ else {
202
+ // 7. The lane card.
203
+ const confirm = deps.confirm ?? ((req) => askConfirm(req));
204
+ const card = laneCardRequest(decision);
205
+ const res = await confirm({ ...card, facts: [...(card.facts ?? []), ...cardFacts] });
206
+ const value = res.value;
207
+ const offered = new Set(card.choices.map((c) => c.value));
208
+ if (!value || value === 'cancel' || !offered.has(value))
209
+ return { content: NOT_CHOSEN };
210
+ if (value === 'confirm') {
211
+ if (!decision.backend || decision.noAppLane)
212
+ return { content: NOT_CHOSEN };
213
+ finalLane = { backend: decision.backend, ui: decision.ui, ...(decision.floorplan ? { floorplan: decision.floorplan } : {}) };
214
+ }
215
+ else {
216
+ const lane = value.slice('switch:'.length);
217
+ if (!value.startsWith('switch:') || !BUILT_LANES.includes(lane) || !decision.allowed.includes(lane)) {
218
+ return { content: NOT_CHOSEN };
219
+ }
220
+ const usable = (p) => (p && (!p.backend || decision.allowed.includes(p.backend)) ? p : undefined);
221
+ const { ui, floorplan } = uiFor(lane, signals, usable(pinR.pin)?.ui ?? usable(stdR.pin)?.ui);
222
+ finalLane = { backend: lane, ui, ...(floorplan ? { floorplan } : {}) };
223
+ decidedBy = 'developer';
224
+ }
225
+ // 8. A CAP lane needs a runtime.
226
+ if (finalLane.backend === 'cap' && !runtime) {
227
+ const rr = await confirm(runtimeCardRequest());
228
+ const answer = rr.value;
229
+ if (!answer || !RUNTIMES.includes(answer))
230
+ return { content: 'Runtime not chosen. Nothing was saved.' };
231
+ try {
232
+ await assertNotCliHome(laneRoot);
233
+ await writeRuntimeAnswer(laneRoot, answer);
234
+ }
235
+ catch (e) {
236
+ const why = e instanceof Error ? e.message : 'the runtime answer could not be saved';
237
+ return {
238
+ content: `${cardLine(why)}\nThe runtime is still unknown, so the lane was not saved. Nothing was saved. ` +
239
+ 'Add the runtime to .cspeach/conventions.toml, then run /abap-app again.',
240
+ };
241
+ }
242
+ runtime = answer;
243
+ }
244
+ }
245
+ // 9. The decision file, the flow state, the queued plan.
246
+ if (finalLane.backend === 'cap' && runtime)
247
+ finalLane = { ...finalLane, runtime };
248
+ // Task 7 review R10 — the shared chain token: CLI state only (the app flow's
249
+ // saved spec-gap path, never a tool argument), contained to cwd or the save root.
250
+ const gap = flow?.specGapPath ? await chainFromToken(ctx.cwd, flow.specGapPath) : undefined;
251
+ const planCmd = gap ? `/abap-plan --from ${gap.token}` : undefined;
252
+ let written;
253
+ try {
254
+ // I3 — one lane root for the write, every pinned read and from.lane.path.
255
+ await assertNotCliHome(laneRoot);
256
+ written = await writeLaneDecision(laneRoot, {
257
+ spec: { path: specRel, sha256: specSha },
258
+ ...(gap ? { specGap: { path: gap.path } } : {}),
259
+ recommendation: decision,
260
+ final: finalLane,
261
+ decidedBy,
262
+ });
263
+ }
264
+ catch (e) {
265
+ return fail(`the lane decision could not be saved: ${e instanceof Error ? cardLine(e.message) : 'write failed'}`);
266
+ }
267
+ setLane(ctx.session, written.path, written.sha256, planCmd);
268
+ const who = decidedBy === 'developer' ? 'the developer'
269
+ : decidedBy === 'pin' ? `project pin (${decision.decidedByPath ?? '.cspeach/conventions.toml'})`
270
+ : decidedBy === 'standards' ? `standards file (${decision.decidedByPath ?? 'unknown file'})`
271
+ : 'the spec and the rules';
272
+ const ui = finalLane.ui === 'none' ? '' : ` + ${finalLane.ui}${finalLane.floorplan ? ` (${finalLane.floorplan})` : ''}`;
273
+ const lines = [
274
+ `Lane: ${finalLane.backend}${ui}${finalLane.runtime ? `, runtime ${finalLane.runtime}` : ''}.`,
275
+ `Decided by: ${cardLine(who)}.`,
276
+ `Decision file: ${written.path}`,
277
+ ];
278
+ if (warnings.length)
279
+ lines.push(...warnings.map((w) => `Warning: ${cardLine(w)}`));
280
+ // M1 — the classic REPL has no card listener, so the rules took the lane:
281
+ // tell the person at the terminal in one plain line (no inquirer fallback).
282
+ if (headless && (deps.noCardTerminal ?? defaultNoCardTerminal)()) {
283
+ const shown = `${finalLane.backend}${ui}${finalLane.runtime ? `, runtime ${finalLane.runtime}` : ''}`;
284
+ ctx.chunkEmitter?.emit('info', `Lane chosen by the rules (no confirm card in this terminal): ${shown}.`);
285
+ }
286
+ if (flow?.specGapPath && planCmd) {
287
+ ctx.pendingDispatch?.set(planCmd);
288
+ lines.push('Next: the plan is being created.');
289
+ }
290
+ else if (flow?.specGapPath && headless) {
291
+ // I4(c) — headless / one-shot: no flow reaches a later run.
292
+ lines.push(interactiveRunLine(flow.specToken));
293
+ }
294
+ else if (flow?.specGapPath) {
295
+ // I4(b) — the flow is at the plan step: this typed command keeps it.
296
+ lines.push(`The spec-gap file path could not be used to queue the plan. To go on, run: /abap-plan --from ${shownToken(path.basename(flow.specGapPath), 'spec-gap file')}`);
297
+ }
298
+ else {
299
+ lines.push('No spec-gap file in this session yet, so no plan was queued.');
300
+ if (headless)
301
+ lines.push(interactiveRunLine(specRel));
302
+ }
303
+ return { content: lines.join('\n') };
304
+ }
305
+ registerTool({
306
+ name: 'app_lane_resolve',
307
+ description: 'App lane routing for /abap-app. Pass the spec file path and the signal sheet: for each fixed key a value and, ' +
308
+ 'when the value is true / a service name, an evidence quote copied word for word from the spec (max 300 chars). ' +
309
+ 'The CLI checks the quotes, reads the system facts, the project pin and the standards file, picks the lane, ' +
310
+ 'shows the lane card, saves the decision file and queues the plan. Never guess a lane yourself. ' +
311
+ 'Keys: app_requested, ui_only_service (string|null), report_only, analytical, transactional, custom_ui, ' +
312
+ 'cap_external_users, cap_multi_system, cap_own_release_cycle, cap_ui_on_released_api.',
313
+ isMutating: false,
314
+ requiresSap: false,
315
+ category: 'project',
316
+ flagGated: true,
317
+ input_schema: {
318
+ type: 'object',
319
+ properties: {
320
+ specPath: { type: 'string', description: 'Spec file, relative to the workspace (.md, .txt, .docx, .pdf, .xlsx).' },
321
+ signals: {
322
+ type: 'object',
323
+ description: 'One entry per key: { value, evidence? }. evidence = an exact quote from the spec.',
324
+ },
325
+ },
326
+ required: ['specPath', 'signals'],
327
+ },
328
+ handler: (args, ctx) => appLaneResolveHandler(args, ctx),
329
+ });
@@ -82,6 +82,8 @@ export const LOCAL_BUILD_TOOLS = [
82
82
  'sap_link',
83
83
  // CAP phase 2 (Task 11) — S/4 link check: live read vs direct S/4, dev-only update-then-restore.
84
84
  'cap_link_smoke',
85
+ // App lane routing (slice 1, Task 5) — reads the spec file like read_document; writes only .cspeach/lane + [app-lane].
86
+ 'app_lane_resolve',
85
87
  'extend_model_insert',
86
88
  'system_capability',
87
89
  'agent_run',
@@ -109,6 +111,8 @@ export const STANDALONE_FILE_TOOLS = [
109
111
  'file_edit',
110
112
  'glob',
111
113
  'grep',
114
+ // App lane routing — /abap-app in standalone mode decides the lane on declared facts.
115
+ 'app_lane_resolve',
112
116
  ];
113
117
  export function applyStandaloneFileTools(cwd) {
114
118
  enableToolFlags([...STANDALONE_FILE_TOOLS]);
@@ -11,7 +11,10 @@
11
11
  * (no ensureWorkspace side effects from a tool call); only bare tokens
12
12
  * trigger the workspace lookup.
13
13
  */
14
- import { basename, isAbsolute, join } from 'node:path';
14
+ import { basename, isAbsolute, join, resolve } from 'node:path';
15
+ import { promises as fsp } from 'node:fs';
16
+ import { isDenylistedPath, isInsideRoot } from '../_filesystem-shared.js';
17
+ import { workspaceFolderFor } from '../../lane/roots.js';
15
18
  import { readProjectFile } from '../../projects/status.js';
16
19
  import { resolveAtTokenAsync, formatProjectFileList } from '../../projects/workspace.js';
17
20
  export function labelFor(path) {
@@ -42,8 +45,40 @@ export async function resolveEnvelopeToken(rawToken, cwd) {
42
45
  * Resolve + read + type-check one envelope input. readProjectFile
43
46
  * already validates the envelope schema and throws readable errors.
44
47
  */
48
+ /**
49
+ * SEC (review round 1): the merge tools must not be a file-existence / JSON
50
+ * oracle for arbitrary paths (`~/.cspeach/auth.json`, `../../other/x`). An
51
+ * input is read only when its REAL path is
52
+ * - inside the project folder (cwd) and not a protected path, or
53
+ * - inside the resolved workspace folder (which may sit outside cwd, e.g. a
54
+ * git-root `.cspeach/` or a configured `workspace_folder`) AND named
55
+ * `*.cspeach.json` — a saved envelope, never a secret.
56
+ * The spelled path is checked BEFORE the disk is touched, so an outside path
57
+ * gets the same answer whether it exists or not.
58
+ */
59
+ async function assertEnvelopeInputAllowed(rawToken, filePath, cwd) {
60
+ const refused = new Error(`'${rawToken}' is outside the project and its workspace folder, or a protected path — `
61
+ + 'merge inputs must be saved .cspeach.json envelopes inside this project or its workspace folder.');
62
+ const abs = resolve(cwd, filePath);
63
+ const workspace = await workspaceFolderFor(cwd);
64
+ const isEnvelope = /\.cspeach\.json$/i.test(basename(abs));
65
+ const allowedAt = (p) => (isInsideRoot(p, cwd) && !isDenylistedPath(p, cwd).blocked)
66
+ || (isEnvelope && /\.cspeach\.json$/i.test(basename(p)) && isInsideRoot(p, workspace));
67
+ if (!allowedAt(abs))
68
+ throw refused;
69
+ let real;
70
+ try {
71
+ real = await fsp.realpath(abs);
72
+ }
73
+ catch {
74
+ throw new Error(`cannot read '${rawToken}': file not found`);
75
+ }
76
+ if (!allowedAt(real))
77
+ throw refused;
78
+ }
45
79
  export async function loadEnvelope(rawToken, cwd, expectedType) {
46
80
  const path = await resolveEnvelopeToken(rawToken, cwd);
81
+ await assertEnvelopeInputAllowed(rawToken, path, cwd);
47
82
  let envelope;
48
83
  try {
49
84
  envelope = readProjectFile(path);
@@ -29,6 +29,7 @@ import { dirname, isAbsolute, join } from 'node:path';
29
29
  import { registerTool } from '../index.js';
30
30
  import { mergeCcaAssessments, renderCcaMergeManifest, } from '../../projects/merge-cca.js';
31
31
  import { loadEnvelope, MANIFEST_PASTE_INSTRUCTIONS } from './_merge-shared.js';
32
+ import { assertWritable } from '../_filesystem-shared.js';
32
33
  export async function ccaMergeHandler(args, ctx) {
33
34
  if (!Array.isArray(args.inputs) || args.inputs.some((i) => typeof i !== 'string')) {
34
35
  return { content: 'error: inputs must be an array of envelope paths / @-tokens.', is_error: true };
@@ -99,6 +100,9 @@ export async function ccaMergeHandler(args, ctx) {
99
100
  let detailWritten = true;
100
101
  const detailAbs = isAbsolute(merged.detailPath) ? merged.detailPath : join(ctx.cwd, merged.detailPath);
101
102
  try {
103
+ // SEC (review round 2): the real landing path must stay in the project and
104
+ // not be protected (a junctioned .cspeach/<domain> -> .git is refused).
105
+ await assertWritable(detailAbs, ctx.cwd);
102
106
  mkdirSync(dirname(detailAbs), { recursive: true });
103
107
  writeFileSync(detailAbs, JSON.stringify({
104
108
  generatedAt: new Date().toISOString(),
@@ -31,6 +31,8 @@ import { dirname, isAbsolute, join } from 'node:path';
31
31
  import { registerTool } from '../index.js';
32
32
  import { mergeUpgradeProgress, renderUpgradeMergeManifest, } from '../../projects/merge-upgrade.js';
33
33
  import { loadEnvelope, MANIFEST_PASTE_INSTRUCTIONS } from './_merge-shared.js';
34
+ import { assertWritable } from '../_filesystem-shared.js';
35
+ import { resolveDetailFile } from '../../projects/detail-path.js';
34
36
  function sha256OfFile(path) {
35
37
  return createHash('sha256').update(readFileSync(path)).digest('hex');
36
38
  }
@@ -88,19 +90,25 @@ export async function upgradeMergeProgressHandler(args, ctx) {
88
90
  const scanContent = scan.envelope.content;
89
91
  // baseline_sha256 is the sha256 of the baseline DETAIL JSON body (the
90
92
  // upgrade-fix manifest contract), not of the envelope.
91
- const detailAbs = isAbsolute(scanContent.detailPath)
92
- ? scanContent.detailPath
93
- : join(ctx.cwd, scanContent.detailPath);
93
+ // SEC (review round 2): detailPath comes from INSIDE the scan envelope
94
+ // (model-writable). It is resolved only to a real file in a
95
+ // `.cspeach/upgrades/` working folder of this project or its workspace,
96
+ // never a protected path. Refused and missing get ONE message that never
97
+ // echoes the path — no existence oracle, no hash of an arbitrary file.
98
+ const unavailable = {
99
+ content: "error: the scan's baseline detail file is not available. The merge needs the scan's detail JSON "
100
+ + 'on this machine, saved under .cspeach/upgrades/ in this project or its workspace folder.',
101
+ is_error: true,
102
+ };
103
+ const detailReal = await resolveDetailFile(scanContent.detailPath, 'upgrades', ctx.cwd);
104
+ if (!detailReal)
105
+ return unavailable;
94
106
  let sha256;
95
107
  try {
96
- sha256 = sha256OfFile(detailAbs);
108
+ sha256 = sha256OfFile(detailReal);
97
109
  }
98
- catch (e) {
99
- return {
100
- content: `error: cannot hash baseline detail file '${scanContent.detailPath}' (resolved: ${detailAbs}): `
101
- + `${e instanceof Error ? e.message : String(e)}. The merge needs the scan's detail JSON on this machine.`,
102
- is_error: true,
103
- };
110
+ catch {
111
+ return unavailable;
104
112
  }
105
113
  baseline = {
106
114
  sha256,
@@ -143,6 +151,9 @@ export async function upgradeMergeProgressHandler(args, ctx) {
143
151
  let detailWritten = true;
144
152
  const mergedDetailAbs = isAbsolute(merged.detailPath) ? merged.detailPath : join(ctx.cwd, merged.detailPath);
145
153
  try {
154
+ // SEC (review round 2): the real landing path must stay in the project and
155
+ // not be protected (a junctioned .cspeach/<domain> -> .git is refused).
156
+ await assertWritable(mergedDetailAbs, ctx.cwd);
146
157
  mkdirSync(dirname(mergedDetailAbs), { recursive: true });
147
158
  writeFileSync(mergedDetailAbs, JSON.stringify({
148
159
  generatedAt: new Date().toISOString(),
@@ -53,6 +53,8 @@ export const SKILL_STARTERS = Object.freeze({
53
53
  'abap-transport': TRANSPORT_FAMILY,
54
54
  'abap-transport-analysis': [...TRANSPORT_FAMILY, 'sap_atc_run', 'sap_usage_references'],
55
55
  'abap-cap': ['fiori_scaffold_fe', 'fiori_render_smoke', 'fiori_draft_smoke'],
56
+ // Both flag-gated (LOCAL_BUILD_TOOLS / STANDALONE_FILE_TOOLS): skipped when off.
57
+ 'abap-app': ['app_lane_resolve', 'read_document'],
56
58
  'abap-atc-fix': ['sap_atc_run'],
57
59
  'abap-cca': ['sap_atc_run', 'sap_usage_references', 'sap_api_state'],
58
60
  'abap-clean-core': ['sap_api_state', 'sap_atc_run', 'sap_usage_references'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.23",
3
+ "version": "1.1.25",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",