@cspeach/cli 1.1.33 → 1.1.35

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 (119) hide show
  1. package/dist/agent/anthropic-provider.js +2 -0
  2. package/dist/agent/azure-profile.js +7 -0
  3. package/dist/agent/connection-profile.js +49 -0
  4. package/dist/agent/loop.js +182 -39
  5. package/dist/agent/provider-shape.js +36 -0
  6. package/dist/agent/providers/ai-hub-provider.js +6 -5
  7. package/dist/agent/providers/azure-provider.js +125 -0
  8. package/dist/agent/providers/byok-provider.js +8 -7
  9. package/dist/agent/providers/factory.js +8 -3
  10. package/dist/agent/providers/local-provider.js +18 -22
  11. package/dist/agent/risk-gate.js +202 -0
  12. package/dist/agent/summarise-via-provider.js +55 -8
  13. package/dist/agent/tool-dispatch.js +19 -0
  14. package/dist/agent/turn-error-ux.js +22 -0
  15. package/dist/auth/api-key.js +10 -5
  16. package/dist/auth/auth-file.js +35 -6
  17. package/dist/auth/azure/azure-key.js +21 -0
  18. package/dist/auth/azure/azure-log.js +45 -0
  19. package/dist/auth/azure/cache-store.js +171 -0
  20. package/dist/auth/azure/clear.js +28 -0
  21. package/dist/auth/azure/loopback.js +192 -0
  22. package/dist/auth/azure/msal-client.js +41 -0
  23. package/dist/auth/azure/sign-in.js +219 -0
  24. package/dist/auth/azure/startup-sign-in.js +64 -0
  25. package/dist/auth/azure/token-provider.js +116 -0
  26. package/dist/auth/key-resolver.js +258 -0
  27. package/dist/auth/me.js +11 -5
  28. package/dist/cli.js +21 -2
  29. package/dist/commands/compact.js +12 -1
  30. package/dist/commands/config-set.js +74 -3
  31. package/dist/commands/config-show.js +25 -0
  32. package/dist/commands/cost.js +19 -6
  33. package/dist/commands/help.js +2 -2
  34. package/dist/commands/login-azure.js +33 -0
  35. package/dist/commands/login.js +56 -8
  36. package/dist/commands/logout.js +34 -10
  37. package/dist/commands/plan-boundary.js +4 -4
  38. package/dist/commands/plan-model-tier.js +8 -3
  39. package/dist/commands/usage.js +6 -0
  40. package/dist/commands/whoami.js +113 -9
  41. package/dist/config/loader.js +5 -0
  42. package/dist/connection/azure-auth.js +135 -0
  43. package/dist/connection/azure-errors.js +103 -0
  44. package/dist/connection/azure-facts.js +21 -0
  45. package/dist/connection/azure-gate.js +40 -0
  46. package/dist/connection/azure-last-call.js +5 -0
  47. package/dist/connection/azure-turn-error.js +25 -0
  48. package/dist/connection/cache.js +70 -0
  49. package/dist/connection/fetch.js +33 -0
  50. package/dist/connection/guard.js +94 -0
  51. package/dist/connection/limit-gate.js +26 -0
  52. package/dist/connection/local-usage.js +71 -0
  53. package/dist/connection/login-step.js +62 -0
  54. package/dist/connection/startup.js +67 -0
  55. package/dist/connection/state.js +68 -0
  56. package/dist/connection/types.js +1 -0
  57. package/dist/cost/cost-log.js +3 -1
  58. package/dist/cost/credits-wire.js +1 -1
  59. package/dist/cost/credits.js +4 -0
  60. package/dist/cost/display-mode.js +7 -0
  61. package/dist/cost/router-spend.js +3 -0
  62. package/dist/cost/session-cost.js +4 -0
  63. package/dist/doctor/checks/azure.js +100 -0
  64. package/dist/doctor/checks/credits.js +4 -3
  65. package/dist/doctor/checks/usage-reporting.js +22 -0
  66. package/dist/doctor/run.js +7 -0
  67. package/dist/lib/graceful-exit.js +17 -0
  68. package/dist/licence/licence-check.js +205 -0
  69. package/dist/licence/paths.js +6 -0
  70. package/dist/licence/turn-gate.js +66 -0
  71. package/dist/models/model-identity.js +65 -0
  72. package/dist/models/resolve.js +8 -0
  73. package/dist/models/server-config.js +3 -2
  74. package/dist/one-shot.js +86 -2
  75. package/dist/projects/answer-blockers.js +25 -12
  76. package/dist/renderer/change-card.js +28 -4
  77. package/dist/renderer/footer-line.js +7 -1
  78. package/dist/renderer/notices.js +27 -13
  79. package/dist/renderer/progress-chatter.js +3 -1
  80. package/dist/renderer/status-footer.js +13 -6
  81. package/dist/renderer/tool-labels.js +8 -2
  82. package/dist/renderer/verify-chain.js +41 -18
  83. package/dist/renderer/wrap-rows.js +87 -0
  84. package/dist/repl/ask-or-no.js +18 -0
  85. package/dist/repl/builtin-commands.js +10 -2
  86. package/dist/repl/post-turn-status.js +3 -2
  87. package/dist/repl/safety-confirm.js +47 -2
  88. package/dist/repl/session-spend-line.js +4 -1
  89. package/dist/repl/startup-account.js +51 -0
  90. package/dist/repl.js +217 -48
  91. package/dist/router/byok-classifier.js +41 -14
  92. package/dist/router/classifier.js +5 -16
  93. package/dist/router/router-target.js +41 -0
  94. package/dist/sap/connection-manager.js +15 -0
  95. package/dist/sap/onboarding.js +70 -64
  96. package/dist/sap/session-end.js +66 -0
  97. package/dist/session/audit-export.js +11 -4
  98. package/dist/skills/manifest-client.js +37 -4
  99. package/dist/skills/skills-cache.js +115 -0
  100. package/dist/skills/source-authed.js +276 -0
  101. package/dist/tools/ask-question.js +1 -1
  102. package/dist/tools/filesystem/glob.js +12 -3
  103. package/dist/tools/filesystem/grep.js +14 -3
  104. package/dist/tools/sap-read.js +22 -22
  105. package/dist/tools/sap-write.js +249 -18
  106. package/dist/tools/snapshot.js +26 -0
  107. package/dist/tools/tool-loading.js +9 -10
  108. package/dist/ui/app.js +19 -9
  109. package/dist/ui/approval-modal.js +37 -5
  110. package/dist/ui/live-area.js +14 -1
  111. package/dist/ui/widgets/ask-card.js +183 -107
  112. package/dist/ui/widgets/coaching-picker.js +22 -1
  113. package/dist/ui/widgets/confirm-card.js +102 -76
  114. package/dist/usage/usage-queue.js +323 -0
  115. package/dist/usage/usage-reporter.js +270 -0
  116. package/package.json +4 -3
  117. package/dist/skills/bundled-skills.js +0 -286
  118. package/dist/skills/source-bundled.js +0 -20
  119. package/dist/skills/source-manifest.js +0 -36
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Security batch item 2 — skill text only for signed-in seats (byok, ai-hub,
3
+ * local). The manifest stays public and signed (older CLIs verify it, and the
4
+ * routing list comes from it). Bodies come from GET /v1/skills/:name with the
5
+ * CSPeach bearer, and each body's sha256 is checked against the SIGNED
6
+ * manifest entry — the server-claimed hash is never trusted.
7
+ *
8
+ * Public bodies (manifest.cspeach.dev/v1/skills-public/…) are read only with
9
+ * CSPEACH_SKILLS_PUBLIC_FALLBACK=1, for one release; the route returns 410 in
10
+ * the next. Honest limit: any licensed seat can read the skill text — this is
11
+ * licence compliance, not secrecy.
12
+ */
13
+ import { sha256 } from '@noble/hashes/sha256';
14
+ import { bytesToHex } from '@noble/hashes/utils';
15
+ import { ManifestClient, DEFAULT_MANIFEST_URL, SkillIntegrityError, ManifestUnavailableError, } from './manifest-client.js';
16
+ import { fileSkillsCache } from './skills-cache.js';
17
+ // Defined next to the client that throws it (Task 8 (a): typed, never string-matched).
18
+ export { SkillIntegrityError };
19
+ export class SkillNotInPlanError extends Error {
20
+ constructor(name) {
21
+ super(`The skill /${name} is not in your CSPeach plan. Ask your admin to add it.`);
22
+ this.name = 'SkillNotInPlanError';
23
+ }
24
+ }
25
+ export class SkillsUnavailableError extends Error {
26
+ constructor(detail) {
27
+ super(`Skills can't be loaded right now (${detail}). Try again in a few minutes.`);
28
+ this.name = 'SkillsUnavailableError';
29
+ }
30
+ }
31
+ /**
32
+ * The server answered, but not with a usable skill: a 4xx other than 401/403
33
+ * (404, 429, …) or a 200 without skill text. Deliberately NOT a
34
+ * SkillsUnavailableError: only network failures and 5xx may open the disk
35
+ * cache or the public fallback.
36
+ */
37
+ export class SkillFetchError extends Error {
38
+ constructor(detail) {
39
+ super(`Skills can't be loaded right now (${detail}). Try again in a few minutes.`);
40
+ this.name = 'SkillFetchError';
41
+ }
42
+ }
43
+ export class SkillSignInError extends Error {
44
+ constructor() {
45
+ super('CSPeach did not accept your sign-in for skills. Run cspeach login.');
46
+ this.name = 'SkillSignInError';
47
+ }
48
+ }
49
+ export function publicFallbackEnabled(env = process.env) {
50
+ return env.CSPEACH_SKILLS_PUBLIC_FALLBACK === '1';
51
+ }
52
+ /** The one resolver's key (Task 4), for providers built without a bearer. */
53
+ export async function defaultBearer() {
54
+ const { getStore } = await import('../auth/api-key.js');
55
+ return (await (await getStore()).get()) ?? '';
56
+ }
57
+ const FETCH_TIMEOUT_MS = 10_000;
58
+ /** GET /v1/skills/:name with the CSPeach bearer; the body is hashed against the SIGNED manifest entry. */
59
+ async function fetchSignedInBody(baseUrl, name, expectedSha, getBearer, doFetch) {
60
+ // A missing key is a sign-in problem, settled before any network call.
61
+ let bearer;
62
+ try {
63
+ bearer = await getBearer();
64
+ }
65
+ catch {
66
+ throw new SkillSignInError();
67
+ }
68
+ if (typeof bearer !== 'string' || bearer.trim() === '')
69
+ throw new SkillSignInError();
70
+ let r;
71
+ try {
72
+ r = await doFetch(`${baseUrl}/v1/skills/${encodeURIComponent(name)}`, {
73
+ headers: { Authorization: `Bearer ${bearer}` },
74
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
75
+ });
76
+ }
77
+ catch {
78
+ throw new SkillsUnavailableError('could not reach CSPeach');
79
+ }
80
+ if (r.status === 403)
81
+ throw new SkillNotInPlanError(name);
82
+ if (r.status === 401)
83
+ throw new SkillSignInError();
84
+ if (r.status >= 500)
85
+ throw new SkillsUnavailableError(`the server answered ${r.status}`);
86
+ if (!r.ok)
87
+ throw new SkillFetchError(`the server answered ${r.status}`);
88
+ const j = await r.json().catch(() => null);
89
+ if (!j || typeof j.body !== 'string')
90
+ throw new SkillFetchError('the answer had no skill text');
91
+ const actual = bytesToHex(sha256(new TextEncoder().encode(j.body)));
92
+ if (actual !== expectedSha) {
93
+ throw new SkillIntegrityError(`skill ${name}: sha256 mismatch with the signed manifest (manifest=${expectedSha}, computed=${actual})`);
94
+ }
95
+ return { name, body: j.body, sha256: actual, signature: '', signedAt: typeof j.signedAt === 'string' ? j.signedAt : '' };
96
+ }
97
+ export function createAuthedSkillSource(cfg, getBearer, deps = {}) {
98
+ const manifestUrl = deps.manifestUrl ?? DEFAULT_MANIFEST_URL;
99
+ const client = deps.client ?? new ManifestClient(manifestUrl);
100
+ const doFetch = deps.fetchImpl ?? fetch;
101
+ const env = deps.env ?? process.env;
102
+ const cache = deps.cache === undefined ? fileSkillsCache : deps.cache;
103
+ const baseUrl = cfg.proxy_url.replace(/\/$/, '');
104
+ let manifest = null;
105
+ const memory = new Map();
106
+ // The existing debug channel (CSPEACH_DEBUG=1 → stderr). Never user output.
107
+ function debugIntegrity(err) {
108
+ if (env.CSPEACH_DEBUG !== '1')
109
+ return;
110
+ process.stderr.write(`[skills] ${err.debug}\n`);
111
+ }
112
+ async function getManifest() {
113
+ if (manifest)
114
+ return manifest;
115
+ try {
116
+ manifest = await client.fetchAndVerifyManifest();
117
+ if (cache) {
118
+ await cache.saveManifest(manifest);
119
+ await cache.prune(manifest);
120
+ }
121
+ }
122
+ catch (err) {
123
+ // Only "could not reach it" (network / 5xx) may use the disk copy. A bad
124
+ // signature, a 4xx or bad JSON never does.
125
+ if (!(err instanceof ManifestUnavailableError))
126
+ throw err;
127
+ const fromDisk = cache ? await cache.loadManifest((m) => client.verifyManifest(m)) : null;
128
+ if (!fromDisk)
129
+ throw err;
130
+ manifest = fromDisk; // offline: the signed copy on disk, re-verified just now
131
+ }
132
+ return manifest;
133
+ }
134
+ const fetchSignedIn = (name, expectedSha) => fetchSignedInBody(baseUrl, name, expectedSha, getBearer, doFetch);
135
+ async function fetchPublic(name, entry) {
136
+ try {
137
+ return await client.fetchSkillBody(name, entry.body_url, { expectedSha256: entry.sha256 });
138
+ }
139
+ catch (err) {
140
+ if (err instanceof SkillIntegrityError)
141
+ throw err;
142
+ if (/HTTP 410\b/.test(String(err?.message))) {
143
+ throw new SkillsUnavailableError('the public skill route is retired; run cspeach login and unset CSPEACH_SKILLS_PUBLIC_FALLBACK');
144
+ }
145
+ throw new SkillsUnavailableError('the public skill source did not answer');
146
+ }
147
+ }
148
+ return {
149
+ async getSkillBody(name) {
150
+ const m = await getManifest();
151
+ const entry = m.skills.find((s) => s.name === name);
152
+ if (!entry)
153
+ throw new Error(`skill '${name}' not found in manifest (available: ${m.skills.map((s) => s.name).join(', ')})`);
154
+ const inMemory = memory.get(name);
155
+ if (inMemory && inMemory.sha256 === entry.sha256)
156
+ return inMemory;
157
+ let body;
158
+ try {
159
+ try {
160
+ body = await fetchSignedIn(name, entry.sha256);
161
+ if (cache)
162
+ await cache.saveBody(entry, body);
163
+ }
164
+ catch (err) {
165
+ // No longer in the plan: drop the cached copy so a later outage
166
+ // cannot serve it.
167
+ if (err instanceof SkillNotInPlanError && cache)
168
+ await cache.removeBody(name);
169
+ // Network / 5xx: a valid cached copy first (hash-checked against the
170
+ // signed manifest), then — only with the switch — the public body.
171
+ // 401 / 403 / a hash mismatch never fall back.
172
+ if (!(err instanceof SkillsUnavailableError))
173
+ throw err;
174
+ const fromDisk = cache ? await cache.loadBody(entry) : null;
175
+ if (fromDisk)
176
+ body = fromDisk;
177
+ else if (publicFallbackEnabled(env))
178
+ body = await fetchPublic(name, entry);
179
+ else
180
+ throw err;
181
+ }
182
+ }
183
+ catch (err) {
184
+ if (err instanceof SkillIntegrityError)
185
+ debugIntegrity(err);
186
+ throw err;
187
+ }
188
+ memory.set(name, body);
189
+ return body;
190
+ },
191
+ async listAvailableSkills() {
192
+ return (await getManifest()).skills.map((s) => s.name);
193
+ },
194
+ describe() {
195
+ return `signed manifest at ${manifestUrl}; bodies from ${baseUrl} for signed-in seats`;
196
+ },
197
+ };
198
+ }
199
+ // ── Skills cache warm-up (final review finding 1) ─────────────────────────
200
+ // "Sign in once, then work offline": after an ok licence verdict in byok /
201
+ // ai-hub / local, fill the disk cache with every skill body that is missing or
202
+ // out of date — through the same signed-in, hash-checked fetch and the same
203
+ // cache as getSkillBody. Fire-and-forget: never awaited on the start-up or
204
+ // turn path, every error swallowed, at most once per process. Managed does no
205
+ // work at all (its skills come from the proxy on every request).
206
+ const WARM_CONCURRENCY = 4;
207
+ export async function warmSkillsCache(cfg, getBearer, deps = {}, opts = {}) {
208
+ const result = { fetched: 0, skipped: 0, stopped: null };
209
+ const cache = deps.cache === undefined ? fileSkillsCache : deps.cache;
210
+ if (!cache)
211
+ return result;
212
+ const client = deps.client ?? new ManifestClient(deps.manifestUrl ?? DEFAULT_MANIFEST_URL);
213
+ const doFetch = deps.fetchImpl ?? fetch;
214
+ const baseUrl = cfg.proxy_url.replace(/\/$/, '');
215
+ let m;
216
+ try {
217
+ m = await client.fetchAndVerifyManifest();
218
+ await cache.saveManifest(m);
219
+ await cache.prune(m);
220
+ }
221
+ catch {
222
+ return result; // offline or a bad manifest: nothing to warm
223
+ }
224
+ // One key read for the whole warm-up, not one per skill.
225
+ let bearerP = null;
226
+ const bearerOnce = () => (bearerP ??= getBearer());
227
+ const queue = [...m.skills];
228
+ const worker = async () => {
229
+ for (let entry = queue.shift(); entry && !result.stopped; entry = queue.shift()) {
230
+ try {
231
+ if (await cache.loadBody(entry)) {
232
+ result.skipped++;
233
+ continue;
234
+ }
235
+ const body = await fetchSignedInBody(baseUrl, entry.name, entry.sha256, bearerOnce, doFetch);
236
+ await cache.saveBody(entry, body);
237
+ result.fetched++;
238
+ }
239
+ catch (err) {
240
+ // Coordinator ruling: only a 401 / sign-in failure stops the warm-up.
241
+ if (err instanceof SkillSignInError)
242
+ result.stopped = 'sign-in';
243
+ // 403: this one skill is not in the plan — drop any cached copy (as
244
+ // getSkillBody does) and carry on with the rest.
245
+ else if (err instanceof SkillNotInPlanError)
246
+ await cache.removeBody(entry.name).catch(() => { });
247
+ // 5xx / network, a hash mismatch or another 4xx: skip this body, carry
248
+ // on, never retry; nothing is cached for it.
249
+ }
250
+ }
251
+ };
252
+ const n = Math.max(1, Math.min(opts.concurrency ?? WARM_CONCURRENCY, queue.length));
253
+ await Promise.all(Array.from({ length: n }, worker));
254
+ return result;
255
+ }
256
+ let warmStarted = false;
257
+ /** Test hook: forget that this process already warmed. */
258
+ export function _resetSkillsWarmupForTests() {
259
+ warmStarted = false;
260
+ }
261
+ /**
262
+ * Start the warm-up when the mode is not managed and the licence verdict is
263
+ * ok. Returns at once: null when nothing was started, else a promise that
264
+ * never rejects (callers drop it; tests await it).
265
+ */
266
+ export function startSkillsCacheWarmup(cfg, verdict, opts = {}) {
267
+ if (cfg.llm.mode === 'managed' || verdict !== 'ok' || warmStarted)
268
+ return null;
269
+ warmStarted = true;
270
+ try {
271
+ return warmSkillsCache(cfg, opts.getBearer ?? defaultBearer, opts.deps).then(() => undefined, () => undefined);
272
+ }
273
+ catch {
274
+ return Promise.resolve();
275
+ }
276
+ }
@@ -251,7 +251,7 @@ registerTool({
251
251
  },
252
252
  multiSelect: {
253
253
  type: 'boolean',
254
- description: 'true = the user may pick several options; false/omitted = pick exactly one.',
254
+ description: 'true = the user may pick several options; false/omitted = pick exactly one. When the user ticks options and also types an answer, it comes back as free text (custom: true): the ticked labels, then the typed text, comma-separated.',
255
255
  },
256
256
  options: {
257
257
  type: 'array',
@@ -20,6 +20,7 @@ import { promises as fs } from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import { registerTool } from '../index.js';
22
22
  import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, isReadOnlyProjectFile, isSeatReadOnlyFile, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
23
+ import { credentialFileRisk, credentialSkipNote } from '../../agent/risk-gate.js';
23
24
  import { isReadOnlyRel, isWorkRel, teamWorkRoot, teamWorkTarget } from '../_team-work-path.js';
24
25
  const MAX_RESULTS = 500;
25
26
  const MAX_FILES_TRAVERSED = 10_000;
@@ -120,6 +121,7 @@ export async function globHandler(args, ctx) {
120
121
  const wantsCspeach = isCspeachPattern(args.pattern);
121
122
  const collected = [];
122
123
  let budget = MAX_FILES_TRAVERSED;
124
+ let credentialSkipped = 0;
123
125
  async function walk(dir, relPrefix, denyRoot = root) {
124
126
  if (budget <= 0 || collected.length >= MAX_RESULTS * 4)
125
127
  return;
@@ -162,6 +164,12 @@ export async function globHandler(args, ctx) {
162
164
  const denylist = isDenylistedPath(abs, denyRoot);
163
165
  if (denylist.blocked && !isReadOnlyProjectFile(abs, denyRoot))
164
166
  continue;
167
+ // Security batch item 4, fix round 1 (F2) — credential files are not
168
+ // listed (same rule as the risk gate); one line says how many.
169
+ if (credentialFileRisk(ent.name)) {
170
+ credentialSkipped++;
171
+ continue;
172
+ }
165
173
  let mtime = 0;
166
174
  try {
167
175
  const s = await fs.stat(abs);
@@ -221,17 +229,18 @@ export async function globHandler(args, ctx) {
221
229
  // Sort by mtime descending, cap at MAX_RESULTS
222
230
  collected.sort((a, b) => b.mtime - a.mtime);
223
231
  const sample = collected.slice(0, MAX_RESULTS).map((f) => f.relPath);
232
+ const skipNote = credentialSkipped > 0 ? `\n${credentialSkipNote(credentialSkipped)}` : '';
224
233
  if (sample.length === 0) {
225
234
  // A search for saved results that did not name `.cspeach/` gets the hint.
226
235
  const hint = !wantsCspeach && /\.json$/i.test(args.pattern)
227
236
  ? ' (saved CSPeach results live in .cspeach/ — search there with pattern ".cspeach/*.cspeach.json")'
228
237
  : '';
229
- return { content: `no files match "${args.pattern}"${hint}` };
238
+ return { content: `no files match "${args.pattern}"${hint}${skipNote}` };
230
239
  }
231
240
  const truncated = collected.length > MAX_RESULTS;
232
- const trailer = truncated
241
+ const trailer = (truncated
233
242
  ? `\n... (${collected.length - MAX_RESULTS} more matches — narrow the pattern)`
234
- : '';
243
+ : '') + skipNote;
235
244
  return { content: sample.join('\n') + trailer };
236
245
  }
237
246
  registerTool({
@@ -23,6 +23,7 @@ import { registerTool } from '../index.js';
23
23
  import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
24
24
  import { globToRegex } from './glob.js';
25
25
  import { teamWorkTarget } from '../_team-work-path.js';
26
+ import { credentialFileRisk, credentialSkipNote } from '../../agent/risk-gate.js';
26
27
  const MAX_MATCHES = 250;
27
28
  const MAX_FILES_TRAVERSED = 10_000;
28
29
  const MAX_FILE_BYTES = 2_000_000;
@@ -69,6 +70,11 @@ export async function grepHandler(args, ctx) {
69
70
  const matches = [];
70
71
  const fileMatchCounts = new Map();
71
72
  let budget = MAX_FILES_TRAVERSED;
73
+ // Security batch item 4, fix round 1 (F2) — credential files (.npmrc,
74
+ // .git-credentials, .env* outside the denylist, *.pem, *.key) are never
75
+ // searched: grep would return their text with no card. Counted, and one
76
+ // line says so. Same rule as the risk gate, so the lists cannot drift.
77
+ let credentialSkipped = 0;
72
78
  async function walk(dir, relPrefix) {
73
79
  if (budget <= 0 || matches.length >= MAX_MATCHES)
74
80
  return;
@@ -106,6 +112,10 @@ export async function grepHandler(args, ctx) {
106
112
  const denylist = isDenylistedPath(abs, root);
107
113
  if (denylist.blocked)
108
114
  continue;
115
+ if (credentialFileRisk(ent.name)) {
116
+ credentialSkipped++;
117
+ continue;
118
+ }
109
119
  let stat;
110
120
  try {
111
121
  stat = await fs.stat(abs);
@@ -158,13 +168,14 @@ export async function grepHandler(args, ctx) {
158
168
  }
159
169
  }
160
170
  await walk(realRoot, '');
171
+ const skipNote = credentialSkipped > 0 ? `\n${credentialSkipNote(credentialSkipped)}` : '';
161
172
  if (matches.length === 0) {
162
- return { content: `no matches for /${args.pattern}/${args.case_insensitive ? 'i' : ''}` };
173
+ return { content: `no matches for /${args.pattern}/${args.case_insensitive ? 'i' : ''}${skipNote}` };
163
174
  }
164
175
  const truncated = matches.length >= MAX_MATCHES;
165
- const trailer = truncated
176
+ const trailer = (truncated
166
177
  ? `\n... (cap of ${MAX_MATCHES} matches reached — narrow the pattern or use a glob filter)`
167
- : '';
178
+ : '') + skipNote;
168
179
  if (outputMode === 'files_with_matches') {
169
180
  const files = [...new Set(matches.map((m) => m.file))];
170
181
  return { content: files.join('\n') + trailer };
@@ -644,29 +644,29 @@ registerTool({
644
644
  handler: async (args, ctx) => {
645
645
  // runUnitTest(type, name) — type first
646
646
  const result = await ctx.adt.runUnitTest(args.type, args.name);
647
- // B2 false-green guard (project_aunit_run_gap): the ADT AUnit-run endpoint
648
- // returns HTTP 200 with an EMPTY runResult (total:0, no programs) even for a
649
- // class that is active + syntax-clean with a valid FOR TESTING local class —
650
- // a known environmental gap, exhaustively probed (sap-client buildUnitTestXml
651
- // comment). The raw {total:0} shape reads to a caller like "0 tests, nothing
652
- // failed → green", so an unguarded pass-through risks a false-green test gate.
653
- // When we detect that empty signature, wrap the (still-carried) raw result in
654
- // an explicit UNVERIFIED envelope that names Eclipse "Run As → ABAP Unit" as
655
- // the authoritative check. A genuine run (total > 0) passes through unchanged.
656
- const summary = result?.summary;
657
- const programs = result?.programs;
658
- const noTestsRun = summary?.total === 0 && (!Array.isArray(programs) || programs.length === 0);
659
- if (noTestsRun) {
647
+ // B2 false-green guard: a run in which no test actually ran reads to a
648
+ // caller like "nothing failed → green". Wrap the (still-carried) raw result
649
+ // in an explicit UNVERIFIED envelope when:
650
+ // - total is 0, missing or not a number — even when programs are present
651
+ // (a class with no methods, or only tolerable class alerts; review I1);
652
+ // - every test was skipped (total > 0, passed 0, failed 0; review M1).
653
+ // It is no longer blamed on an ADT gap: the 2026-10-12 sap-client fix sends
654
+ // the request shape Eclipse sends. A run with a pass or a failure passes
655
+ // through unchanged.
656
+ const summary = result
657
+ ?.summary;
658
+ const total = summary?.total;
659
+ const noTestsRun = typeof total !== 'number' || !Number.isFinite(total) || total === 0;
660
+ const allSkipped = !noTestsRun && summary?.passed === 0 && summary?.failed === 0;
661
+ if (noTestsRun || allSkipped) {
662
+ const n = total;
663
+ const warning = noTestsRun
664
+ ? '0 tests ran — this is not a pass. Check that the object has an active test ' +
665
+ 'class (FOR TESTING) with active test methods, then run it again.'
666
+ : `${n === 1 ? '1 test was' : `${n} tests were`} skipped and none ran — this is not ` +
667
+ 'a pass. Check the risk level and duration of the test classes, then run it again.';
660
668
  return {
661
- content: JSON.stringify({
662
- verification: 'UNVERIFIED',
663
- warning: 'ABAP Unit run enumerated 0 tests (empty runResult). This does NOT mean the tests ' +
664
- 'passed — it means none executed. A FOR TESTING class that is active + syntax-clean ' +
665
- 'still returning 0 is a known ADT AUnit-run gap (project_aunit_run_gap): the ' +
666
- 'authoritative check is Eclipse "Run As → ABAP Unit" (Ctrl+Shift+F10). Do NOT treat ' +
667
- 'this as a green test gate.',
668
- result,
669
- }, null, 2),
669
+ content: JSON.stringify({ verification: 'UNVERIFIED', warning, result }, null, 2),
670
670
  };
671
671
  }
672
672
  return { content: JSON.stringify(result, null, 2) };