@cspeach/cli 1.1.27 → 1.1.29
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/dist/agent/loop.js +33 -4
- package/dist/agent/provider-shape.js +9 -2
- package/dist/agent/providers/ai-hub-provider.js +2 -2
- package/dist/agent/providers/byok-provider.js +2 -2
- package/dist/agent/providers/local-provider.js +4 -4
- package/dist/agent/system-prompt.js +34 -7
- package/dist/agent/tool-dispatch.js +20 -1
- package/dist/agent/turn-error-ux.js +40 -0
- package/dist/approvals/grant-claims.js +70 -0
- package/dist/approvals/jwt.js +21 -1
- package/dist/approvals/plan-grant.js +297 -0
- package/dist/commands/config-set.js +12 -0
- package/dist/commands/deploy-card.js +10 -5
- package/dist/commands/export-audit.js +1 -1
- package/dist/commands/plan-audit-evidence.js +47 -1
- package/dist/commands/plan-audit.js +23 -8
- package/dist/commands/plan-batch-audit.js +349 -0
- package/dist/commands/plan-boundary.js +88 -0
- package/dist/commands/plan-chain.js +409 -566
- package/dist/commands/plan-continue.js +21 -7
- package/dist/commands/plan-gate.js +28 -49
- package/dist/commands/plan-reground.js +174 -0
- package/dist/commands/plan-resume.js +227 -103
- package/dist/commands/plan-sap-pause.js +105 -0
- package/dist/commands/plan-startup.js +218 -0
- package/dist/config/loader.js +9 -0
- package/dist/doctor/checks/forge-rules.js +67 -85
- package/dist/doctor/run.js +4 -4
- package/dist/lane/app-chain.js +88 -9
- package/dist/one-shot.js +1 -0
- package/dist/projects/extract-plan.js +34 -1
- package/dist/projects/handover-md.js +1 -1
- package/dist/projects/plan-objects.js +75 -0
- package/dist/projects/plan-run.js +74 -18
- package/dist/projects/plan-schema.js +6 -0
- package/dist/projects/plan-sources.js +199 -0
- package/dist/projects/promote-command.js +1 -1
- package/dist/projects/save-command.js +11 -13
- package/dist/projects/saved-lines.js +36 -0
- package/dist/renderer/checklist-format.js +5 -5
- package/dist/renderer/machine-blocks.js +141 -0
- package/dist/renderer/tool-labels.js +3 -0
- package/dist/repl/plan-hop.js +22 -0
- package/dist/repl/rule8-detector.js +16 -1
- package/dist/repl.js +320 -55
- package/dist/router/router-core.js +2 -2
- package/dist/sap/reachability-state.js +25 -0
- package/dist/session/audit-export.js +10 -16
- package/dist/session/recap.js +16 -26
- package/dist/skills/bundled-skills.js +81 -81
- package/dist/tools/_filesystem-shared.js +37 -0
- package/dist/tools/approval.js +48 -2
- package/dist/tools/dispatch-skill.js +3 -2
- package/dist/tools/filesystem/file-read.js +5 -3
- package/dist/tools/filesystem/glob.js +27 -4
- package/dist/tools/fiori/tools.js +6 -0
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/sap-read.js +37 -3
- package/dist/tools/sap-write.js +65 -0
- package/dist/tools/text-elements/pool.js +250 -0
- package/dist/tools/text-elements/tools.js +433 -0
- package/dist/tools/tool-loading.js +3 -0
- package/dist/tools/transport.js +26 -5
- package/dist/tools/write-mode.js +4 -3
- package/dist/ui/app.js +4 -0
- package/dist/ui/widgets/hold-raw-mode.js +26 -0
- package/package.json +120 -121
- package/dist/projects/email-template.js +0 -56
|
@@ -371,6 +371,43 @@ export function isDenylistedPath(realAbsPath, root, platform = process.platform)
|
|
|
371
371
|
}
|
|
372
372
|
return { blocked: false, relFromRoot };
|
|
373
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* READ-ONLY carve-out (2026-10-04, plan sources): the project's own saved
|
|
376
|
+
* results — `.cspeach/<name>.cspeach.json` (spec-gap, plan, design, … envelopes,
|
|
377
|
+
* directly in `.cspeach/`) and `.cspeach/lane/<name>.json` (lane decisions) —
|
|
378
|
+
* are project data the developer saved, not secrets. file_read and glob may
|
|
379
|
+
* READ / LIST them; file_write / file_edit never consult this, so the
|
|
380
|
+
* envelopes stay CLI-owned for writes (audits, statuses and `from` refs can
|
|
381
|
+
* never be forged through a file tool).
|
|
382
|
+
*
|
|
383
|
+
* Narrow by construction — true only when ALL hold:
|
|
384
|
+
* - the path is NOT under the CLI home (`~/.cspeach`: auth, config, sessions),
|
|
385
|
+
* whatever the root (a project run from the home folder gets nothing);
|
|
386
|
+
* - it sits inside the spelled root, at exactly one of the two shapes above
|
|
387
|
+
* (no deeper nesting, no other `.cspeach` file such as conventions.toml);
|
|
388
|
+
* - no NTFS alias spelling: no ':' segment, no trailing dot/space segment,
|
|
389
|
+
* and no '~' segment (a possible 8.3 short name — fail closed);
|
|
390
|
+
* - the basename is not in the `.env` family.
|
|
391
|
+
* `.git` / `.cspeach-design` are never matched (the shapes require `.cspeach/`).
|
|
392
|
+
*/
|
|
393
|
+
export function isReadOnlyProjectFile(realAbsPath, root, platform = process.platform) {
|
|
394
|
+
const isWin = platform === 'win32';
|
|
395
|
+
const native = platform === process.platform;
|
|
396
|
+
const p = isWin ? path.win32 : path.posix;
|
|
397
|
+
if (isUnderCliHome(realAbsPath, p, native))
|
|
398
|
+
return false;
|
|
399
|
+
const rel = p.relative(p.resolve(root), realAbsPath).replace(/\\/g, '/');
|
|
400
|
+
if (!relIsInside(p, rel) || rel === '')
|
|
401
|
+
return false;
|
|
402
|
+
const segments = rel.split('/');
|
|
403
|
+
if (segments.some((seg) => seg.includes(':') || seg.includes('~') || ntfsCanonicalSegment(seg) !== seg))
|
|
404
|
+
return false;
|
|
405
|
+
if (BLOCKED_BASENAME_RE.test(segments[segments.length - 1]))
|
|
406
|
+
return false;
|
|
407
|
+
const relLower = rel.toLowerCase();
|
|
408
|
+
return /^\.cspeach\/[^/]+\.cspeach\.json$/.test(relLower)
|
|
409
|
+
|| /^\.cspeach\/lane\/[^/]+\.json$/.test(relLower);
|
|
410
|
+
}
|
|
374
411
|
export class PathOutsideRootError extends Error {
|
|
375
412
|
constructor(givenPath, root) {
|
|
376
413
|
super(`Path "${givenPath}" resolves outside the project root "${root}"`);
|
package/dist/tools/approval.js
CHANGED
|
@@ -17,7 +17,9 @@ function noteApprovedTransport(t) {
|
|
|
17
17
|
import { mintChangeApproval } from '../approvals/jwt.js';
|
|
18
18
|
import { canonicalApprovalObject } from '../approvals/canonical.js';
|
|
19
19
|
import { renderPlanGate, renderPerChangeApprovalV3 } from '../approvals/render.js';
|
|
20
|
-
import { loadConfig } from '../config/loader.js';
|
|
20
|
+
import { loadConfig, effectiveApprovals } from '../config/loader.js';
|
|
21
|
+
import { getPlanGrant, grantCovers, noteGrantUse, noteApprovalsSetting, grantClaimsFor } from '../approvals/plan-grant.js';
|
|
22
|
+
import { normalizeObjectType } from '../projects/plan-objects.js';
|
|
21
23
|
import { maybeShowAutoApproveNag } from '../approvals/approval-prompt.js';
|
|
22
24
|
import { renderAdvisoryProposal, } from '../approvals/advisory-render.js';
|
|
23
25
|
import { promptAdvisory } from '../approvals/advisory-prompt.js';
|
|
@@ -161,7 +163,9 @@ registerTool({
|
|
|
161
163
|
// sanctioned rule-4a self-create. The very act of routing through
|
|
162
164
|
// request_approval IS the user-consent gate, so it must NOT be denied here
|
|
163
165
|
// — fall through to the normal approval flow (which prompts the user, whose
|
|
164
|
-
// approval is the 4a consent
|
|
166
|
+
// approval is the 4a consent; or, for the ONE new transport of a plan that
|
|
167
|
+
// had none, the plan object-list grant, whose "a new one for this build"
|
|
168
|
+
// the user already approved). Guarded-mode clients could otherwise never
|
|
165
169
|
// run 4a: this backstop blocks it BEFORE the audit even runs. TIGHT: the
|
|
166
170
|
// exception fires only when EVERY change is infra; a deliverable
|
|
167
171
|
// (CLAS/DDLS/…) or a mixed set still denies (correct out-of-lane B3).
|
|
@@ -202,6 +206,48 @@ registerTool({
|
|
|
202
206
|
const changes = args.changes;
|
|
203
207
|
const declared = args.risk;
|
|
204
208
|
const eff = effectiveRisk(declared, changes, riskCtx);
|
|
209
|
+
// 2026-10-04 (continuous run, spec §3.2) — the plan object-list grant. The
|
|
210
|
+
// developer approved these exact objects on "Build this now?" (or
|
|
211
|
+
// "Continue"); a request whose EVERY change is on that list is approved by
|
|
212
|
+
// that human answer — not an auto-approval, so it applies in guarded runs.
|
|
213
|
+
// Anything else (off the list, another package or transport, a delete, a
|
|
214
|
+
// release, a deploy) falls through to the cards below. The advisory clamp
|
|
215
|
+
// above already returned for advisory/production; approvals = per-write
|
|
216
|
+
// turns this off; a headless run never uses it.
|
|
217
|
+
// A change that is really high risk (declared high, or an object whose last
|
|
218
|
+
// syntax check found errors) always shows its card, listed or not. The
|
|
219
|
+
// auto_approve = never setting also lifts every request to high, but that
|
|
220
|
+
// is a setting, not a risk (see shownRiskReasons), so it is left out here.
|
|
221
|
+
// The approvals setting is recorded for Rule 8's batch card as well.
|
|
222
|
+
const grant = getPlanGrant();
|
|
223
|
+
const approvalsSetting = grant ? effectiveApprovals(cfg) : null;
|
|
224
|
+
if (approvalsSetting)
|
|
225
|
+
noteApprovalsSetting(approvalsSetting);
|
|
226
|
+
if (grant && !isHeadless() && approvalsSetting === 'plan'
|
|
227
|
+
&& effectiveRisk(declared, changes, { ...riskCtx, autoApprovePolicy: 'medium' }).level !== 'high'
|
|
228
|
+
&& ctx.session?.id === grant.sessionId
|
|
229
|
+
&& grantCovers(grant, changes, args.transport)) {
|
|
230
|
+
noteGrantUse(changes);
|
|
231
|
+
const ids = [];
|
|
232
|
+
for (const c of changes) {
|
|
233
|
+
// The grant's one new transport: its id creates ONE transport, never more.
|
|
234
|
+
const oneShot = c.op === 'create' && normalizeObjectType(c.type ?? '') === 'CTS';
|
|
235
|
+
// Grant claims: the write tools hold the id to the listed type,
|
|
236
|
+
// package and bound transport.
|
|
237
|
+
ids.push(await mintChangeApproval(c, ctx.session.id, { grant: grantClaimsFor(c), ...(oneShot ? { oneShot: true } : {}) }));
|
|
238
|
+
}
|
|
239
|
+
markPlanGateApproved();
|
|
240
|
+
noteApprovedTransport(args.transport);
|
|
241
|
+
return {
|
|
242
|
+
content: JSON.stringify({
|
|
243
|
+
approved: true,
|
|
244
|
+
approved_by: 'the plan object list',
|
|
245
|
+
approval_ids: ids,
|
|
246
|
+
effective_risk: eff.level,
|
|
247
|
+
raised_by: eff.raisedBy.map(riskReasonText),
|
|
248
|
+
}),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
205
251
|
// Transport shown on the approval surfaces — resolved PER OP to mirror
|
|
206
252
|
// what the write tools will actually do (I2, 2026-07-05 review):
|
|
207
253
|
// create-family tools fall back to the session transport, so the box may
|
|
@@ -33,9 +33,10 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { registerTool } from './index.js';
|
|
35
35
|
import { isPlanResumeCommand } from '../commands/plan-resume.js';
|
|
36
|
+
export const PLAN_RESUME_DISPATCH_REASON = 'Do not dispatch /abap-plan --resume. When your plan manifest is saved, CSPeach runs the next step itself with the exact saved file. Simply end the turn now.';
|
|
36
37
|
registerTool({
|
|
37
38
|
name: 'dispatch_skill',
|
|
38
|
-
description: 'Queue the next user submission to a slash command the user already explicitly approved via an inquirer picker. Use this in place of telling the user "Type X at the next prompt" — re-typing what they already picked is friction. Only call this AFTER an explicit user consent (ask_question pick, picker confirmation, etc.); never from prose or a one-line suggestion. The command must start with a known slash route (e.g. "/abap-rap --from @<file>"). NEVER dispatch "/abap-plan --resume" — plan continuation is
|
|
39
|
+
description: 'Queue the next user submission to a slash command the user already explicitly approved via an inquirer picker. Use this in place of telling the user "Type X at the next prompt" — re-typing what they already picked is friction. Only call this AFTER an explicit user consent (ask_question pick, picker confirmation, etc.); never from prose or a one-line suggestion. The command must start with a known slash route (e.g. "/abap-rap --from @<file>"). NEVER dispatch "/abap-plan --resume" — plan continuation is owned by CSPeach: when the manifest is saved, CSPeach runs the next step itself. After this tool returns, end the turn — do not also print a continuation hint.',
|
|
39
40
|
isMutating: false,
|
|
40
41
|
input_schema: {
|
|
41
42
|
type: 'object',
|
|
@@ -75,7 +76,7 @@ registerTool({
|
|
|
75
76
|
is_error: true,
|
|
76
77
|
content: JSON.stringify({
|
|
77
78
|
error: 'PLAN_RESUME_HARNESS_OWNED',
|
|
78
|
-
reason:
|
|
79
|
+
reason: PLAN_RESUME_DISPATCH_REASON,
|
|
79
80
|
}),
|
|
80
81
|
};
|
|
81
82
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { promises as fs } from 'node:fs';
|
|
12
12
|
import { registerTool } from '../index.js';
|
|
13
|
-
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath } from '../_filesystem-shared.js';
|
|
13
|
+
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, isReadOnlyProjectFile } from '../_filesystem-shared.js';
|
|
14
14
|
import { spillToolResult, isUnderSpillRoot } from '../result-spill.js';
|
|
15
15
|
import { basename } from 'node:path';
|
|
16
16
|
const MAX_LINES = 2000;
|
|
@@ -45,9 +45,11 @@ export async function fileReadHandler(args, ctx) {
|
|
|
45
45
|
}
|
|
46
46
|
return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
47
47
|
}
|
|
48
|
-
// Denylist check: refuse sensitive in-root paths
|
|
48
|
+
// Denylist check: refuse sensitive in-root paths. The project's own saved
|
|
49
|
+
// results (.cspeach/*.cspeach.json, .cspeach/lane/*.json) are readable —
|
|
50
|
+
// isReadOnlyProjectFile; the write tools never get this carve-out.
|
|
49
51
|
const denylist = isDenylistedPath(realAbs, ctx.cwd);
|
|
50
|
-
if (denylist.blocked) {
|
|
52
|
+
if (denylist.blocked && !isReadOnlyProjectFile(realAbs, ctx.cwd)) {
|
|
51
53
|
return {
|
|
52
54
|
content: `error: refusing to read sensitive path "${denylist.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`,
|
|
53
55
|
is_error: true,
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { promises as fs } from 'node:fs';
|
|
20
20
|
import * as path from 'node:path';
|
|
21
21
|
import { registerTool } from '../index.js';
|
|
22
|
-
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
|
|
22
|
+
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, isReadOnlyProjectFile, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
|
|
23
23
|
const MAX_RESULTS = 500;
|
|
24
24
|
const MAX_FILES_TRAVERSED = 10_000;
|
|
25
25
|
const ALWAYS_SKIPPED_DIRS = new Set([
|
|
@@ -70,6 +70,16 @@ export function globToRegex(pattern) {
|
|
|
70
70
|
out += '$';
|
|
71
71
|
return new RegExp(out);
|
|
72
72
|
}
|
|
73
|
+
/** True when the pattern explicitly asks for the project's saved results. */
|
|
74
|
+
export function isCspeachPattern(pattern) {
|
|
75
|
+
const p = pattern.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
|
|
76
|
+
return p.startsWith('.cspeach/') || p.includes('.cspeach.json');
|
|
77
|
+
}
|
|
78
|
+
/** True for a relPath inside the root-level `.cspeach/` folder. */
|
|
79
|
+
function inCspeach(relPath) {
|
|
80
|
+
const l = relPath.toLowerCase();
|
|
81
|
+
return l === '.cspeach' || l.startsWith('.cspeach/');
|
|
82
|
+
}
|
|
73
83
|
export async function globHandler(args, ctx) {
|
|
74
84
|
// Validate args
|
|
75
85
|
if (!args.pattern || typeof args.pattern !== 'string') {
|
|
@@ -97,6 +107,12 @@ export async function globHandler(args, ctx) {
|
|
|
97
107
|
return { content: `error: search root "${args.cwd ?? '.'}" does not exist`, is_error: true };
|
|
98
108
|
}
|
|
99
109
|
const regex = globToRegex(args.pattern);
|
|
110
|
+
// 2026-10-04 (plan sources) — the project's saved results live in
|
|
111
|
+
// `.cspeach/` (skipped by default: noise + CLI-owned). A pattern that
|
|
112
|
+
// names them (`.cspeach/...` or `*.cspeach.json`) walks the ROOT-level
|
|
113
|
+
// `.cspeach/` folder; each file there is listed only when it is readable
|
|
114
|
+
// (isReadOnlyProjectFile, or a carved-out work file).
|
|
115
|
+
const wantsCspeach = isCspeachPattern(args.pattern);
|
|
100
116
|
const collected = [];
|
|
101
117
|
let budget = MAX_FILES_TRAVERSED;
|
|
102
118
|
async function walk(dir, relPrefix) {
|
|
@@ -125,7 +141,10 @@ export async function globHandler(args, ctx) {
|
|
|
125
141
|
continue;
|
|
126
142
|
}
|
|
127
143
|
if (ent.isDirectory()) {
|
|
128
|
-
|
|
144
|
+
const intoCspeach = wantsCspeach && dir === realRoot && ent.name.toLowerCase() === '.cspeach';
|
|
145
|
+
// Inside the walked `.cspeach/` every subfolder is entered (lane/, the
|
|
146
|
+
// work subdirs); the per-file read check decides what is listed.
|
|
147
|
+
if (ALWAYS_SKIPPED_DIRS.has(ent.name) && !intoCspeach && !inCspeach(relPrefix))
|
|
129
148
|
continue;
|
|
130
149
|
await walk(path.join(dir, ent.name), relPath);
|
|
131
150
|
}
|
|
@@ -136,7 +155,7 @@ export async function globHandler(args, ctx) {
|
|
|
136
155
|
// Denylist check on the matched path
|
|
137
156
|
const abs = path.join(dir, ent.name);
|
|
138
157
|
const denylist = isDenylistedPath(abs, ctx.cwd);
|
|
139
|
-
if (denylist.blocked)
|
|
158
|
+
if (denylist.blocked && !isReadOnlyProjectFile(abs, ctx.cwd))
|
|
140
159
|
continue;
|
|
141
160
|
let mtime = 0;
|
|
142
161
|
try {
|
|
@@ -178,7 +197,11 @@ export async function globHandler(args, ctx) {
|
|
|
178
197
|
collected.sort((a, b) => b.mtime - a.mtime);
|
|
179
198
|
const sample = collected.slice(0, MAX_RESULTS).map((f) => f.relPath);
|
|
180
199
|
if (sample.length === 0) {
|
|
181
|
-
|
|
200
|
+
// A search for saved results that did not name `.cspeach/` gets the hint.
|
|
201
|
+
const hint = !wantsCspeach && /\.json$/i.test(args.pattern)
|
|
202
|
+
? ' (saved CSPeach results live in .cspeach/ — search there with pattern ".cspeach/*.cspeach.json")'
|
|
203
|
+
: '';
|
|
204
|
+
return { content: `no files match "${args.pattern}"${hint}` };
|
|
182
205
|
}
|
|
183
206
|
const truncated = collected.length > MAX_RESULTS;
|
|
184
207
|
const trailer = truncated
|
|
@@ -774,6 +774,12 @@ export async function fioriDraftSmokeHandler(args, ctx, deps) {
|
|
|
774
774
|
if (!verify.ok) {
|
|
775
775
|
return { content: `error: APPROVAL_INVALID: ${verify.reason} — ${howToApprove}.`, is_error: true };
|
|
776
776
|
}
|
|
777
|
+
// 2026-10-04 — the id must be one minted for this data run (type DATA), not
|
|
778
|
+
// one for the service binding object itself (e.g. a plan-list SRVB change):
|
|
779
|
+
// the smoke writes live rows, so the developer must have approved exactly that.
|
|
780
|
+
if (String(verify.payload.type ?? '').trim().toUpperCase() !== 'DATA') {
|
|
781
|
+
return { content: `error: APPROVAL_INVALID: type_mismatch — ${howToApprove}.`, is_error: true };
|
|
782
|
+
}
|
|
777
783
|
if (draftSmokeApprovalsUsed.has(verify.payload.nonce)) {
|
|
778
784
|
return {
|
|
779
785
|
content: `error: this approval_id already ran one draft smoke — each run needs its own approval; ${howToApprove}.`,
|
|
@@ -306,7 +306,7 @@ registerTool({
|
|
|
306
306
|
name: 'app_lane_resolve',
|
|
307
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
308
|
'when the value is true / a service name, an evidence quote copied word for word from the spec (max 300 chars). ' +
|
|
309
|
-
'
|
|
309
|
+
'CSPeach checks the quotes, reads the system facts, the project pin and the standards file, picks the lane, ' +
|
|
310
310
|
'shows the lane card, saves the decision file and queues the plan. Never guess a lane yourself. ' +
|
|
311
311
|
'Keys: app_requested, ui_only_service (string|null), report_only, analytical, transactional, custom_ui, ' +
|
|
312
312
|
'cap_external_users, cap_multi_system, cap_own_release_cycle, cap_ui_on_released_api.',
|
package/dist/tools/sap-read.js
CHANGED
|
@@ -258,11 +258,33 @@ registerTool({
|
|
|
258
258
|
// -----------------------------------------------------------------------
|
|
259
259
|
// 5. sap_sql_query
|
|
260
260
|
// -----------------------------------------------------------------------
|
|
261
|
+
/**
|
|
262
|
+
* 2026-10-04 (live run, S/4HANA 2023): a design phase guessed
|
|
263
|
+
* `SELECT object_type, object_key … FROM ars_w_api_state` → SAP HTTP 400
|
|
264
|
+
* "Unknown column name OBJECT_KEY", then retried. The columns below are the
|
|
265
|
+
* ones SAP returned for `SELECT * FROM ars_w_api_state` in that run
|
|
266
|
+
* (I_OPERATIONALACCTGDOCITEM: OBJECT_ID = OBJECT_NAME, OBJECT_TYPE DDLS,
|
|
267
|
+
* SUB_OBJECT_TYPE CDS_STOB, COMPATIBILITY_CONTRACT C1, RELEASE_STATE RELEASED).
|
|
268
|
+
*/
|
|
269
|
+
export const ARS_COLUMNS_HINT = 'Release state of an SAP object (C1/C2): SELECT object_id, object_type, compatibility_contract, release_state '
|
|
270
|
+
+ 'FROM ars_w_api_state WHERE object_id LIKE \'I_JOURNALENTRY%\' — columns are OBJECT_ID, OBJECT_TYPE (DDLS for CDS views, '
|
|
271
|
+
+ 'CLAS, BDEF, …), OBJECT_NAME, SUB_OBJECT_TYPE, SUB_OBJECT_NAME, COMPATIBILITY_CONTRACT, RELEASE_STATE, '
|
|
272
|
+
+ 'SUCCESSOR_OBJECT_TYPE, SUCCESSOR_OBJECT_NAME (there is no OBJECT_KEY).';
|
|
273
|
+
/** The hint for a failed ARS_* query that named an unknown column, else undefined. */
|
|
274
|
+
export function arsColumnErrorHint(query, err) {
|
|
275
|
+
if (typeof query !== 'string' || !/\bars_(?:w_)?api_\w+/i.test(query))
|
|
276
|
+
return undefined;
|
|
277
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
278
|
+
if (!/unknown column/i.test(msg))
|
|
279
|
+
return undefined;
|
|
280
|
+
return `${msg}\n${ARS_COLUMNS_HINT}`;
|
|
281
|
+
}
|
|
261
282
|
registerTool({
|
|
262
283
|
name: 'sap_sql_query',
|
|
263
284
|
description: 'Execute a read-only SQL SELECT against the SAP database via ADT Data Preview. Security-sensitive tables are blocked automatically. '
|
|
264
285
|
+ 'Dialect limits (violations return an opaque "SAP HTTP 400: sqlQuery"): no IN-lists and at most one LIKE per WHERE clause '
|
|
265
|
-
+ '(multi-OR LIKE chains are rejected) — use a single LIKE or split into separate queries.'
|
|
286
|
+
+ '(multi-OR LIKE chains are rejected) — use a single LIKE or split into separate queries. '
|
|
287
|
+
+ ARS_COLUMNS_HINT,
|
|
266
288
|
isMutating: false,
|
|
267
289
|
requiresSap: true,
|
|
268
290
|
input_schema: {
|
|
@@ -274,7 +296,18 @@ registerTool({
|
|
|
274
296
|
required: ['query'],
|
|
275
297
|
},
|
|
276
298
|
handler: async (args, ctx) => {
|
|
277
|
-
|
|
299
|
+
let result;
|
|
300
|
+
try {
|
|
301
|
+
result = await ctx.adt.sqlQuery(args.query, args.maxRows ?? 100);
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
// A wrong ARS_W_API_STATE column costs a failed round trip: answer with
|
|
305
|
+
// the real column list so the retry is right the first time.
|
|
306
|
+
const hint = arsColumnErrorHint(args.query, e);
|
|
307
|
+
if (hint)
|
|
308
|
+
return { content: hint, is_error: true };
|
|
309
|
+
throw e;
|
|
310
|
+
}
|
|
278
311
|
return spillToolResult({ content: formatSqlTsv(result) }, ctx, 'sql', { label: 'query result' });
|
|
279
312
|
},
|
|
280
313
|
});
|
|
@@ -391,7 +424,8 @@ registerTool({
|
|
|
391
424
|
// -----------------------------------------------------------------------
|
|
392
425
|
registerTool({
|
|
393
426
|
name: 'sap_api_state',
|
|
394
|
-
description: 'Check the ABAP Cloud / clean-core release state of SAP objects. Pass comma-separated TYPE:NAME pairs, e.g. "CLAS:CL_GUI_ALV_GRID,FUNC:BAPI_SALESORDER_GETLIST"
|
|
427
|
+
description: 'Check the ABAP Cloud / clean-core release state of SAP objects. Pass comma-separated TYPE:NAME pairs, e.g. "CLAS:CL_GUI_ALV_GRID,FUNC:BAPI_SALESORDER_GETLIST" '
|
|
428
|
+
+ '(CDS views: "DDLS:I_JOURNALENTRY"). Returns RELEASE_STATE only; for the contract (C1/C2) query ARS_W_API_STATE with sap_sql_query.',
|
|
395
429
|
isMutating: false,
|
|
396
430
|
requiresSap: true,
|
|
397
431
|
input_schema: {
|
package/dist/tools/sap-write.js
CHANGED
|
@@ -25,6 +25,7 @@ import crypto from 'node:crypto';
|
|
|
25
25
|
import { readFileSync } from 'node:fs';
|
|
26
26
|
import { registerTool } from './index.js';
|
|
27
27
|
import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
|
|
28
|
+
import { grantClaimMismatch, grantActivateMismatch, grantOwningTransportMismatch, grantOwningTransportDetail } from '../approvals/grant-claims.js';
|
|
28
29
|
import { autoSnapshot, autoSnapshotClassInclude } from './snapshot.js';
|
|
29
30
|
import { includeSnapshotType } from './include-snapshot.js';
|
|
30
31
|
import { buildWriteChainLine } from '../renderer/verify-chain.js';
|
|
@@ -177,6 +178,12 @@ registerTool({
|
|
|
177
178
|
is_error: true,
|
|
178
179
|
};
|
|
179
180
|
}
|
|
181
|
+
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
182
|
+
{
|
|
183
|
+
const claim = grantClaimMismatch(verify.payload, { type: args.type, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
184
|
+
if (claim)
|
|
185
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
186
|
+
}
|
|
180
187
|
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
181
188
|
const startedAt = Date.now();
|
|
182
189
|
// ── Rule 9 conflict warning ──────────────────────────────────────────────
|
|
@@ -193,6 +200,13 @@ registerTool({
|
|
|
193
200
|
// If the object is already locked in an open request, write under THAT
|
|
194
201
|
// request — any other transport is a guaranteed CTS lock conflict.
|
|
195
202
|
const resolved = await resolveWriteTransport(ctx, args.type, args.name, args.transport);
|
|
203
|
+
// 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
|
|
204
|
+
if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
|
|
205
|
+
return {
|
|
206
|
+
content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(args.name, resolved.owning) }),
|
|
207
|
+
is_error: true,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
196
210
|
// ── Gate 2: Snapshot (Rule 7) — HARD gate ────────────────────────────────
|
|
197
211
|
// Only a genuine 404 (new object) may proceed snapshot-less. A read
|
|
198
212
|
// failure on a possibly-EXISTING object means no recovery copy → block.
|
|
@@ -352,6 +366,12 @@ registerTool({
|
|
|
352
366
|
is_error: true,
|
|
353
367
|
};
|
|
354
368
|
}
|
|
369
|
+
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
370
|
+
{
|
|
371
|
+
const claim = grantClaimMismatch(verify.payload, { type: 'CLAS', transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
372
|
+
if (claim)
|
|
373
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
374
|
+
}
|
|
355
375
|
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
356
376
|
const startedAt = Date.now();
|
|
357
377
|
// ── Rule 9 conflict warning ──────────────────────────────────────────────
|
|
@@ -366,6 +386,13 @@ registerTool({
|
|
|
366
386
|
}
|
|
367
387
|
// ── Rule 9 owning-transport check (D15/D20) ──────────────────────────────
|
|
368
388
|
const resolved = await resolveWriteTransport(ctx, 'CLAS', args.class_name, args.transport);
|
|
389
|
+
// 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
|
|
390
|
+
if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
|
|
391
|
+
return {
|
|
392
|
+
content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(args.class_name, resolved.owning) }),
|
|
393
|
+
is_error: true,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
369
396
|
// ── Gate 2: Snapshot (Rule 7) — HARD gate ────────────────────────────────
|
|
370
397
|
// sap_update_method always targets an EXISTING class, so a read failure
|
|
371
398
|
// (non-404) means no recovery copy → block. (A genuine 404 falls through
|
|
@@ -674,6 +701,12 @@ registerTool({
|
|
|
674
701
|
is_error: true,
|
|
675
702
|
};
|
|
676
703
|
}
|
|
704
|
+
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
705
|
+
{
|
|
706
|
+
const claim = grantClaimMismatch(verify.payload, { type: args.type, package: args.package, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
707
|
+
if (claim)
|
|
708
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
709
|
+
}
|
|
677
710
|
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
678
711
|
const startedAt = Date.now();
|
|
679
712
|
// No snapshot — new object has no prior source.
|
|
@@ -780,6 +813,12 @@ registerTool({
|
|
|
780
813
|
is_error: true,
|
|
781
814
|
};
|
|
782
815
|
}
|
|
816
|
+
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
817
|
+
{
|
|
818
|
+
const claim = grantClaimMismatch(verify.payload, { type: 'CLAS', transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
819
|
+
if (claim)
|
|
820
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
821
|
+
}
|
|
783
822
|
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
784
823
|
const startedAt = Date.now();
|
|
785
824
|
// ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
|
|
@@ -794,6 +833,13 @@ registerTool({
|
|
|
794
833
|
// The CCIMP piece (LIMU CINC) is exactly the kind of object that sits
|
|
795
834
|
// locked in a request from earlier work — battery S1's 409 root cause.
|
|
796
835
|
const resolved = await resolveWriteTransport(ctx, 'CLAS', args.className, effectiveTransport);
|
|
836
|
+
// 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
|
|
837
|
+
if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
|
|
838
|
+
return {
|
|
839
|
+
content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(args.className, resolved.owning) }),
|
|
840
|
+
is_error: true,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
797
843
|
// ── Gate 2: Snapshot (Rule 7) — of the INCLUDE being overwritten ─────────
|
|
798
844
|
// Not the class main source: for a behaviour pool that is an empty shell and
|
|
799
845
|
// the change lives in CCIMP (2026-09-21 — the old main-source snapshot left
|
|
@@ -928,6 +974,12 @@ registerTool({
|
|
|
928
974
|
is_error: true,
|
|
929
975
|
};
|
|
930
976
|
}
|
|
977
|
+
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
978
|
+
{
|
|
979
|
+
const claim = grantClaimMismatch(verify.payload, { type: 'DOMA', package: args.package, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
980
|
+
if (claim)
|
|
981
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
982
|
+
}
|
|
931
983
|
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
932
984
|
const startedAt = Date.now();
|
|
933
985
|
// No snapshot — new object has no prior source.
|
|
@@ -1061,6 +1113,12 @@ registerTool({
|
|
|
1061
1113
|
is_error: true,
|
|
1062
1114
|
};
|
|
1063
1115
|
}
|
|
1116
|
+
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
1117
|
+
{
|
|
1118
|
+
const claim = grantClaimMismatch(verify.payload, { type: 'DTEL', package: args.package, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
1119
|
+
if (claim)
|
|
1120
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
1121
|
+
}
|
|
1064
1122
|
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
1065
1123
|
const startedAt = Date.now();
|
|
1066
1124
|
// No snapshot — new object has no prior source.
|
|
@@ -1188,6 +1246,13 @@ registerTool({
|
|
|
1188
1246
|
is_error: true,
|
|
1189
1247
|
};
|
|
1190
1248
|
}
|
|
1249
|
+
// 2026-10-04 — a plan-grant id activates only the objects it names (every
|
|
1250
|
+
// member, not just the first), with the listed type for a single object.
|
|
1251
|
+
{
|
|
1252
|
+
const claim = grantActivateMismatch(verify.payload, args.objects);
|
|
1253
|
+
if (claim)
|
|
1254
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
1255
|
+
}
|
|
1191
1256
|
// Round 7 item 8e — an approval auto_approve minted (no human saw it)
|
|
1192
1257
|
// never activates an object whose last syntax check found errors. Any
|
|
1193
1258
|
// approval minted after the errors went through the card (risk floor).
|