@moda-ai/cli 1.20.1 → 1.21.0

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.
@@ -1,172 +1,835 @@
1
- // src/init/sdk-integration.ts — Run an installed coding agent to integrate the
2
- // Moda SDK directly, instead of leaving a guide for the user to action by hand.
3
- //
4
- // This reuses the same coding-agent spawn machinery as `moda harness analyze`
5
- // (see src/harness.ts), but in write mode. The integration knowledge itself
6
- // lives in the bundled integration skills (skills/integration/, authored in
7
- // packages/integration-skills/): we resolve the skill matching the detected
8
- // stack, materialize it into .claude/skills/<id>/, and hand the agent a thin
9
- // prompt that points at the skill file — the prompt no longer inlines API
10
- // documentation.
1
+ // src/init/sdk-integration.ts — Agent-owned SDK integration during `moda init`.
2
+ // The coding agent investigates the repo, applies relevant catalog skills,
3
+ // runs repo checks and a smoke LLM call, and reports via a structured protocol.
4
+ // The CLI enforces deterministic guards and independently verifies the trace.
5
+ import { randomBytes } from 'node:crypto';
6
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
7
+ import { dirname, join, relative, resolve } from 'node:path';
8
+ import { spawnSync } from 'node:child_process';
11
9
  import { codingAgentDisplayName, describeCodingAgentEvent, detectAvailableCodingAgents, runCodingAgent, } from '../harness.js';
12
- // Re-exported for callers/tests that treat this module as the card-rendering
13
- // surface; the implementation lives in harness.ts next to the event parser so
14
- // the agent-stream analyst path can reuse it without an import cycle.
10
+ import { installIntegrationSkill } from '../skills-bundled.js';
11
+ import { cleanupSdkCatalog, materializeSdkCatalog, renderCatalogListing, } from './sdk-catalog.js';
12
+ import { extractSdkAgentResult, SDK_RESULT_FILE, SDK_RESULT_JSON_BEGIN, SDK_RESULT_JSON_END, SDK_RESULT_SCHEMA_VERSION, } from './sdk-protocol.js';
13
+ import { generateVerificationId, verifyExactTrace, } from './sdk-verification.js';
14
+ import { readRuntimeSummary, renderRuntimeSummaryBlock } from './sdk-guide.js';
15
15
  export { describeCodingAgentEvent } from '../harness.js';
16
- import { installIntegrationSkill, readBundledSkillMd } from '../skills-bundled.js';
17
- import { detectSdkGuideContext, renderDetectionBlock, renderSdkIntegrationGuide, stripSkillFrontmatter, } from './sdk-guide.js';
18
- import { resolveIntegrationSkillId } from './skill-resolver.js';
19
- /**
20
- * Pick an installed coding-agent CLI and run it, in write mode, to integrate
21
- * the Moda SDK into the repo. Returns `no-agent` (without throwing) when no
22
- * CLI is installed so the caller can fall back to writing the static guide.
23
- */
16
+ const OUTCOME_SCHEMA_VERSION = 'moda.sdk_integration_outcome.v1';
17
+ const MAX_SCAN_FILE_BYTES = 1_048_576;
18
+ const MAX_SCANNED_FILES = 2_000;
19
+ const MAX_SCAN_DIRECTORIES = 2_000;
20
+ const SECRET_KEY_PATTERN = /moda_sk_[A-Za-z0-9]{8,}/g;
21
+ const DEGRADED_GUARDS_NOTE = 'changed-file guards degraded: not a git repository';
24
22
  export async function runSdkIntegration(options = {}) {
25
23
  const cwd = options.cwd ?? process.cwd();
26
24
  const available = detectAvailableCodingAgents();
27
- if (available.length === 0)
28
- return { status: 'no-agent' };
25
+ if (available.length === 0) {
26
+ return { status: 'agent_unavailable', repair_attempts: 0 };
27
+ }
29
28
  const adapter = options.adapter && available.some((item) => item.adapter === options.adapter)
30
29
  ? options.adapter
31
30
  : available[0].adapter;
32
31
  const human = options.human ?? false;
33
32
  const emit = (message) => {
34
- options.onProgress?.(message);
33
+ const redacted = redactSecretText(message, options.apiKey);
34
+ options.onProgress?.(redacted);
35
35
  if (human)
36
- process.stderr.write(` ${codingAgentDisplayName(adapter)}: ${message}\n`);
36
+ process.stderr.write(` ${codingAgentDisplayName(adapter)}: ${redacted}\n`);
37
37
  };
38
- const context = detectSdkGuideContext(cwd);
39
- const skillId = resolveIntegrationSkillId(context);
40
- let installedSkillId;
41
- let inlineSkill;
42
- if (skillId) {
43
- try {
44
- const install = installIntegrationSkill(skillId, cwd);
45
- installedSkillId = install.skillId;
46
- emit(install.status === 'installed'
47
- ? `Installed integration skill ${skillId} to .claude/skills/${skillId}/`
48
- : `Integration skill ${skillId} already up to date`);
49
- }
50
- catch (err) {
51
- // Materialization failed (unwritable .claude/skills target, a colliding
52
- // user-authored file, or a missing/corrupt bundle). Do NOT silently drop
53
- // the resolved stack to the generic checklist — that would contradict the
54
- // detection block. When the bundle is still readable, inline the skill's
55
- // content into the prompt so the agent keeps the stack-specific steps;
56
- // only a truly absent bundle (e.g. an unbuilt dev tree) falls through to
57
- // the generic guide.
58
- const message = err instanceof Error ? err.message : String(err);
59
- emit(`Could not materialize integration skill ${skillId}: ${message}`);
60
- const skillMd = readBundledSkillMd(skillId);
61
- if (skillMd) {
62
- inlineSkill = { skillId, markdown: skillMd };
63
- emit(`Inlining ${skillId} instructions into the agent prompt instead`);
38
+ const runAgent = options.runAgent ?? runCodingAgent;
39
+ const verify = options.verify ?? verifyExactTrace;
40
+ const maxRepairAttempts = resolveMaxRepairAttempts(options.maxRepairAttempts);
41
+ const runId = randomBytes(4).toString('hex');
42
+ const verificationId = generateVerificationId();
43
+ let catalog;
44
+ let gitBaseline = null;
45
+ let runStartedAt = Date.now();
46
+ let guardsDegraded = false;
47
+ let degradedGuardsEmitted = false;
48
+ const markGuardsDegraded = () => {
49
+ guardsDegraded = true;
50
+ if (degradedGuardsEmitted)
51
+ return;
52
+ degradedGuardsEmitted = true;
53
+ emit(DEGRADED_GUARDS_NOTE);
54
+ };
55
+ try {
56
+ catalog = materializeSdkCatalog(cwd, runId);
57
+ gitBaseline = snapshotGitStatus(cwd);
58
+ runStartedAt = Date.now();
59
+ if (gitBaseline === null)
60
+ markGuardsDegraded();
61
+ }
62
+ catch (error) {
63
+ const detail = error instanceof Error ? error.message : String(error);
64
+ return {
65
+ status: 'agent_failed',
66
+ adapter,
67
+ verification_id: verificationId,
68
+ repair_attempts: 0,
69
+ detail: redactSecretText(`Could not materialize the SDK skill catalog: ${detail}`, options.apiKey),
70
+ };
71
+ }
72
+ const attemptCtx = {
73
+ cwd,
74
+ adapter,
75
+ verificationId,
76
+ catalog,
77
+ gitBaseline,
78
+ apiKey: options.apiKey,
79
+ ingestUrl: options.ingestUrl,
80
+ human,
81
+ emit,
82
+ onEvent: options.onEvent,
83
+ onPhase: options.onPhase,
84
+ runAgent,
85
+ verify,
86
+ fetchAudit: options.fetchAudit,
87
+ verifyTimeoutMs: options.verifyTimeoutMs,
88
+ };
89
+ let repairAttemptsUsed = 0;
90
+ let lastAgentResult;
91
+ let lastVerification;
92
+ let lastSecurityFindings;
93
+ let lastOutcome = { status: 'agent_failed', detail: 'No agent attempt completed.' };
94
+ let installedSkillIds;
95
+ let protocolRetryUsed = false;
96
+ try {
97
+ options.onPhase?.('investigating', 'Investigating repository and integrating the Moda SDK…');
98
+ emit('Starting SDK integration…');
99
+ while (true) {
100
+ const isRepair = repairAttemptsUsed > 0;
101
+ const prompt = isRepair
102
+ ? renderSdkRepairPrompt({
103
+ catalogDir: catalog.relativeDir,
104
+ catalogListing: renderCatalogListing(catalog.index),
105
+ verificationId,
106
+ attempt: repairAttemptsUsed,
107
+ maxAttempts: maxRepairAttempts,
108
+ classification: lastOutcome.repairable ?? 'telemetry_not_observed',
109
+ previousResult: lastAgentResult,
110
+ verification: lastVerification,
111
+ securityFindings: lastSecurityFindings,
112
+ harnessSummary: readRuntimeSummary(cwd),
113
+ })
114
+ : renderSdkIntegrationPrompt({
115
+ catalogDir: catalog.relativeDir,
116
+ catalogListing: renderCatalogListing(catalog.index),
117
+ verificationId,
118
+ harnessSummary: readRuntimeSummary(cwd),
119
+ });
120
+ const run = await invokeAgent(attemptCtx, prompt);
121
+ const extraction = await extractResultWithProtocolRetry(attemptCtx, run, protocolRetryUsed, () => {
122
+ protocolRetryUsed = true;
123
+ });
124
+ if ('agentFailed' in extraction) {
125
+ lastOutcome = extraction.agentFailed;
126
+ break;
127
+ }
128
+ lastAgentResult = sanitizeAgentResult(extraction.result, options.apiKey);
129
+ removeAgentResultFile(cwd);
130
+ const guards = applyDeterministicGuards(cwd, gitBaseline, lastAgentResult, runStartedAt, options.apiKey);
131
+ if (guards.guardsDegraded)
132
+ markGuardsDegraded();
133
+ lastSecurityFindings = guards.securityFindings.length > 0 ? guards.securityFindings : undefined;
134
+ if (guards.securityFindings.length > 0) {
135
+ lastOutcome = {
136
+ status: 'needs_human_input',
137
+ detail: 'remove the leaked key, rotate it, and keep secrets in the environment',
138
+ };
139
+ break;
140
+ }
141
+ if (guards.dishonestIntegrated) {
142
+ lastOutcome = {
143
+ status: 'agent_failed',
144
+ detail: 'agent reported integration but no files changed',
145
+ };
146
+ break;
147
+ }
148
+ lastOutcome = await mapAgentResultToOutcome(attemptCtx, lastAgentResult);
149
+ if (lastOutcome.verification) {
150
+ lastVerification = lastOutcome.verification;
64
151
  }
152
+ if (!lastOutcome.repairable || repairAttemptsUsed >= maxRepairAttempts) {
153
+ break;
154
+ }
155
+ repairAttemptsUsed += 1;
156
+ options.onPhase?.('repairing', `Repairing SDK integration (attempt ${repairAttemptsUsed} of ${maxRepairAttempts})…`);
157
+ emit(`Repair attempt ${repairAttemptsUsed} of ${maxRepairAttempts}…`);
158
+ }
159
+ if (lastOutcome.status === 'integrated_verified' && lastAgentResult) {
160
+ installedSkillIds = installUsedSkills(lastAgentResult, catalog, cwd, emit);
161
+ }
162
+ const finalStatus = lastOutcome.status;
163
+ const finalDetail = composeFinalDetail(finalStatus, lastOutcome.detail, lastVerification, options.apiKey, guardsDegraded);
164
+ writeFinalOutcomeArtifact(cwd, {
165
+ status: finalStatus,
166
+ verification_id: verificationId,
167
+ verification: lastVerification,
168
+ agent_result: lastAgentResult,
169
+ repair_attempts: repairAttemptsUsed,
170
+ security_findings: lastSecurityFindings,
171
+ });
172
+ return {
173
+ status: finalStatus,
174
+ adapter,
175
+ verification_id: verificationId,
176
+ verification: lastVerification,
177
+ agent_result: lastAgentResult,
178
+ repair_attempts: repairAttemptsUsed,
179
+ ...(lastSecurityFindings ? { security_findings: lastSecurityFindings } : {}),
180
+ ...(installedSkillIds ? { installed_skill_ids: installedSkillIds } : {}),
181
+ ...(finalDetail ? { detail: finalDetail } : {}),
182
+ };
183
+ }
184
+ finally {
185
+ cleanupSdkCatalog(catalog.dir);
186
+ }
187
+ }
188
+ export function renderSdkIntegrationPrompt(opts) {
189
+ // Only surface prior harness discovery when it actually found something;
190
+ // an all-zero summary is noise, not context.
191
+ const summary = opts.harnessSummary;
192
+ const hasHarnessSignals = Boolean(summary && (summary.runtimeAgents > 0 || summary.frameworks.length > 0 || summary.providers.length > 0));
193
+ const harnessBlock = summary && hasHarnessSignals
194
+ ? [
195
+ 'Context from Moda\'s earlier harness discovery (may be incomplete):',
196
+ renderRuntimeSummaryBlock(summary),
197
+ '',
198
+ ]
199
+ : [];
200
+ return [
201
+ 'You are running inside `moda init` as a one-shot SDK integration task. The user has',
202
+ 'approved letting you install packages and edit source files in THIS repository to add',
203
+ 'Moda LLM observability. Do the integration now — do not just describe it.',
204
+ '',
205
+ ...SAFETY_RULES,
206
+ '',
207
+ 'Investigation:',
208
+ '- Inspect this repository yourself: workspace/monorepo layout, package manifests and',
209
+ ' lockfiles, scripts, app entry points, LLM clients/wrappers, existing telemetry or',
210
+ ' existing Moda setup, tests/build/typecheck/lint commands, and runtime requirements.',
211
+ '- If Moda is already integrated, do not duplicate it. You MUST still run the smoke',
212
+ ' through the EXISTING integration with the provided verification id, then report',
213
+ ' `nothing_to_integrate` with an honest summary and the `smoke` field set.',
214
+ '',
215
+ ...harnessBlock,
216
+ 'Skill catalog (read relevant skills; multiple may apply):',
217
+ opts.catalogListing,
218
+ '',
219
+ `Read \`${opts.catalogDir}/<id>/SKILL.md\` for every skill relevant to this repo`,
220
+ '(EXAMPLE.md beside it shows the finished integration). Multiple skills may apply',
221
+ '(e.g. a monorepo with a Node service and a Python service, or two providers) —',
222
+ 'apply each relevant one. The catalog is guidance, not a constraint: if the repo\'s',
223
+ 'stack has no matching skill, use the closest skill plus the docs pattern, and say',
224
+ 'so in the result.',
225
+ '',
226
+ 'Repository checks:',
227
+ '- Discover and run this repo\'s own build/typecheck/lint/test commands relevant to',
228
+ ' your changes; record each in commands_run with exit codes.',
229
+ '- If they fail and you cannot fix within the integration scope → status',
230
+ ' repository_checks_failed.',
231
+ '',
232
+ 'Smoke + verification id:',
233
+ `- Run one real request through an instrumented application path that makes an actual`,
234
+ ' LLM call, with the Moda conversation id set to EXACTLY',
235
+ ` \`${opts.verificationId}\` (each skill documents the conversation-id API, e.g.`,
236
+ ' Moda.conversationId / moda.conversation_id).',
237
+ '- Prefer a minimal temporary script under .moda/tmp/ (delete it after) or an existing',
238
+ ' safe entry point — never a destructive path (no prod mutations, no long-running',
239
+ ' servers left up). Flush before exit.',
240
+ '- MODA_API_KEY and MODA_INGEST_URL are already exported in your environment for the',
241
+ ' smoke run — reference them, never print or copy their values.',
242
+ '- If the smoke cannot run (missing provider key, needs a running service you cannot',
243
+ ' start safely) → status smoke_execution_failed or needs_human_input with',
244
+ ' remaining_actions.',
245
+ '',
246
+ renderResultProtocolSection(),
247
+ ].join('\n');
248
+ }
249
+ export function renderSdkRepairPrompt(opts) {
250
+ const evidence = {
251
+ previous_agent_result: opts.previousResult ?? null,
252
+ verification: opts.verification ?? null,
253
+ security_findings: opts.securityFindings ?? [],
254
+ };
255
+ return [
256
+ `Repair attempt ${opts.attempt} of ${opts.maxAttempts} failed: ${opts.classification}`,
257
+ '',
258
+ 'Structured evidence from the previous attempt:',
259
+ JSON.stringify(evidence, null, 2),
260
+ '',
261
+ ...SAFETY_RULES,
262
+ '',
263
+ `Skill catalog directory: ${opts.catalogDir}`,
264
+ opts.catalogListing,
265
+ '',
266
+ `Use the SAME verification id for the smoke run: \`${opts.verificationId}\`.`,
267
+ 'Re-run only what is needed; report honestly via the result protocol.',
268
+ '',
269
+ renderResultProtocolSection(),
270
+ ].join('\n');
271
+ }
272
+ const SAFETY_RULES = [
273
+ 'Scope and safety rules:',
274
+ '- Only touch this repository (the working directory you were launched in).',
275
+ '- Install the Moda SDK with the package manager this repo already uses.',
276
+ '- Instrument the real LLM call sites; do not invent files or frameworks that are not here.',
277
+ '- NEVER write MODA_API_KEY or any secret into source files, and never create or edit',
278
+ ' .env files. The key is already exported in your environment as MODA_API_KEY for the',
279
+ ' smoke run — reference it, never print or copy its value.',
280
+ '- Make the smallest correct change. Match the existing code style and imports.',
281
+ '- Do not modify anything under the ephemeral skill catalog directory.',
282
+ '- Do not run `git commit`.',
283
+ '- Do not edit `.moda/` state files except `.moda/sdk-integration-result.json`.',
284
+ '- If the repo has no LLM calls or something is ambiguous, stop and report honestly',
285
+ ' via the result protocol.',
286
+ ];
287
+ function renderResultProtocolSection() {
288
+ return [
289
+ 'Result protocol (REQUIRED — write file AND print markers as your last output):',
290
+ `- Write \`${SDK_RESULT_FILE}\` with schema_version \`${SDK_RESULT_SCHEMA_VERSION}\`.`,
291
+ `- Print the same JSON between ${SDK_RESULT_JSON_BEGIN} and ${SDK_RESULT_JSON_END}.`,
292
+ 'Fields:',
293
+ '- schema_version (required): must be moda.sdk_integration_result.v1',
294
+ '- status (required): integrated | needs_human_input | repository_checks_failed |',
295
+ ' smoke_execution_failed | nothing_to_integrate | failed',
296
+ '- summary (required): one-line human summary',
297
+ '- skills_consulted: string[] of catalog skill ids you read',
298
+ '- skills_used: string[] of catalog skill ids you applied',
299
+ '- packages_installed: string[]',
300
+ '- files_changed: string[] of repo-relative paths',
301
+ '- call_sites_instrumented: { file, detail? }[] (plain strings accepted as {file})',
302
+ '- commands_run: { command, exit_code, kind? }[]',
303
+ `- smoke: { method, command?, verification_id } | null — verification_id must be exactly`,
304
+ ' the id provided above when smoke ran',
305
+ '- remaining_actions: string[] for the human when blocked',
306
+ '- failure: { class, detail } | null',
307
+ '',
308
+ 'Report honestly: `integrated` means you completed integration, checks passed, and the',
309
+ 'smoke ran — Moda will independently verify the trace; false claims are detected.',
310
+ ].join('\n');
311
+ }
312
+ function resolveMaxRepairAttempts(override) {
313
+ if (override !== undefined && Number.isFinite(override)) {
314
+ return Math.min(3, Math.max(0, Math.trunc(override)));
315
+ }
316
+ const raw = process.env.MODA_SDK_REPAIR_ATTEMPTS;
317
+ if (!raw)
318
+ return 1;
319
+ const parsed = Number(raw);
320
+ if (!Number.isFinite(parsed))
321
+ return 1;
322
+ return Math.min(3, Math.max(0, Math.trunc(parsed)));
323
+ }
324
+ function snapshotGitStatus(cwd) {
325
+ // core.quotePath=off makes git emit non-ASCII paths as raw UTF-8 instead of
326
+ // octal-escaped C-quoted strings ("caf\303\251.ts"), so the secret scan sees
327
+ // real paths for new files/dirs with non-ASCII names. Control characters and
328
+ // quotes are still C-quoted with the escapes unquoteGitPath handles.
329
+ const result = spawnSync('git', ['-c', 'core.quotePath=off', 'status', '--porcelain'], {
330
+ cwd,
331
+ encoding: 'utf8',
332
+ });
333
+ if (result.status !== 0)
334
+ return null;
335
+ return result.stdout ?? '';
336
+ }
337
+ function gitDelta(baseline, cwd) {
338
+ const after = snapshotGitStatus(cwd);
339
+ if (baseline === null || after === null)
340
+ return { lines: [], degraded: true };
341
+ const beforeLines = new Set(baseline.split('\n').filter(Boolean));
342
+ return {
343
+ lines: after.split('\n').filter((line) => line && !beforeLines.has(line)),
344
+ degraded: false,
345
+ };
346
+ }
347
+ export function parseGitDeltaPaths(deltaLines) {
348
+ const paths = [];
349
+ for (const line of deltaLines) {
350
+ if (!line.trim())
351
+ continue;
352
+ const body = line.slice(3).trim();
353
+ if (!body)
354
+ continue;
355
+ const renameAt = body.lastIndexOf(' -> ');
356
+ const rawPath = renameAt >= 0 ? body.slice(renameAt + 4) : body;
357
+ paths.push(unquoteGitPath(rawPath));
358
+ }
359
+ return paths;
360
+ }
361
+ const GIT_QUOTE_ESCAPES = {
362
+ '"': 0x22,
363
+ '\\': 0x5c,
364
+ a: 0x07,
365
+ b: 0x08,
366
+ f: 0x0c,
367
+ n: 0x0a,
368
+ r: 0x0d,
369
+ t: 0x09,
370
+ v: 0x0b,
371
+ };
372
+ /**
373
+ * Decode a git C-quoted porcelain path. Escapes are byte-level (octal \NNN
374
+ * sequences encode UTF-8 bytes), so decoding accumulates bytes and re-decodes
375
+ * as UTF-8 at the end. With core.quotePath=off (see snapshotGitStatus) octal
376
+ * escapes only appear for control bytes, but decode them anyway for safety.
377
+ */
378
+ function unquoteGitPath(path) {
379
+ const trimmed = path.trim();
380
+ if (trimmed.length < 2 || trimmed[0] !== '"' || trimmed.at(-1) !== '"')
381
+ return trimmed;
382
+ const inner = trimmed.slice(1, -1);
383
+ const bytes = [];
384
+ for (let i = 0; i < inner.length; i++) {
385
+ const ch = inner[i];
386
+ if (ch !== '\\') {
387
+ for (const byte of Buffer.from(ch, 'utf8'))
388
+ bytes.push(byte);
389
+ continue;
390
+ }
391
+ const octal = inner.slice(i + 1, i + 4);
392
+ if (/^[0-7]{3}$/.test(octal)) {
393
+ bytes.push(parseInt(octal, 8));
394
+ i += 3;
395
+ continue;
396
+ }
397
+ const next = inner[i + 1];
398
+ if (next !== undefined && GIT_QUOTE_ESCAPES[next] !== undefined) {
399
+ bytes.push(GIT_QUOTE_ESCAPES[next]);
400
+ i += 1;
401
+ continue;
65
402
  }
403
+ bytes.push(0x5c); // Unknown escape: keep the literal backslash.
66
404
  }
67
- const prompt = renderSdkIntegrationPrompt(context, {
68
- installedSkillId,
69
- inlineSkill,
70
- resolvedSkillId: skillId ?? undefined,
405
+ return Buffer.from(bytes).toString('utf8');
406
+ }
407
+ // Filesystem mtime granularity slack (FAT is 2s; also covers same-tick writes).
408
+ const MTIME_SLACK_MS = 2000;
409
+ function filesTouchedSince(cwd, paths, sinceMs) {
410
+ const resolvedCwd = resolve(cwd);
411
+ return paths.some((relPath) => {
412
+ const absPath = resolve(cwd, relPath.replace(/\\/g, '/'));
413
+ if (!absPath.startsWith(resolvedCwd + '/') && absPath !== resolvedCwd)
414
+ return false;
415
+ try {
416
+ return statSync(absPath).mtimeMs >= sinceMs - MTIME_SLACK_MS;
417
+ }
418
+ catch {
419
+ return false;
420
+ }
71
421
  });
72
- const run = await runCodingAgent({
73
- adapter,
422
+ }
423
+ function applyDeterministicGuards(cwd, gitBaseline, result, runStartedAt, apiKey) {
424
+ const delta = gitDelta(gitBaseline, cwd);
425
+ const deltaPaths = parseGitDeltaPaths(delta.lines);
426
+ const candidatePaths = uniquePaths([...deltaPaths, ...result.files_changed]);
427
+ const securityFindings = scanForSecrets(cwd, candidatePaths, apiKey);
428
+ // When the git delta is trustworthy an "integrated" claim needs real
429
+ // evidence of change: either a new status line, or a claimed file whose
430
+ // mtime moved during the run. The mtime fallback exists because the delta is
431
+ // a status-line set difference, so edits to a file that was already dirty at
432
+ // baseline (or committed by the agent) produce no new line. A fabricated
433
+ // files_changed list pointing at untouched files corroborates nothing.
434
+ // Degraded mode (not a git repository) keeps the self-report-only check.
435
+ const claimCorroborated = deltaPaths.length > 0
436
+ || filesTouchedSince(cwd, result.files_changed, runStartedAt);
437
+ const dishonestIntegrated = result.status === 'integrated'
438
+ && (delta.degraded ? result.files_changed.length === 0 : !claimCorroborated);
439
+ return { securityFindings, dishonestIntegrated, guardsDegraded: delta.degraded };
440
+ }
441
+ function uniquePaths(paths) {
442
+ return [...new Set(paths.map((p) => p.replace(/\\/g, '/')).filter(Boolean))];
443
+ }
444
+ function scanForSecrets(cwd, paths, apiKey) {
445
+ const findings = [];
446
+ const resolvedCwd = resolve(cwd);
447
+ const scanPaths = collectSecretScanFiles(cwd, paths);
448
+ for (const relPath of scanPaths) {
449
+ const absPath = resolve(cwd, relPath);
450
+ if (!absPath.startsWith(resolvedCwd + '/') && absPath !== resolvedCwd)
451
+ continue;
452
+ if (!existsSync(absPath))
453
+ continue;
454
+ let stat;
455
+ try {
456
+ stat = statSync(absPath);
457
+ }
458
+ catch {
459
+ continue;
460
+ }
461
+ if (!stat.isFile())
462
+ continue;
463
+ // Oversized files are scanned up to MAX_SCAN_FILE_BYTES rather than skipped
464
+ // outright, so a leaked key hidden in a large (e.g. generated) file can't
465
+ // evade the guard by size alone.
466
+ let content;
467
+ try {
468
+ content = stat.size > MAX_SCAN_FILE_BYTES
469
+ ? readFilePrefix(absPath, MAX_SCAN_FILE_BYTES)
470
+ : readFileSync(absPath, 'utf8');
471
+ }
472
+ catch {
473
+ continue;
474
+ }
475
+ if (content.includes('\0'))
476
+ continue;
477
+ let leaked = false;
478
+ if (apiKey && content.includes(apiKey))
479
+ leaked = true;
480
+ SECRET_KEY_PATTERN.lastIndex = 0;
481
+ if (SECRET_KEY_PATTERN.test(content))
482
+ leaked = true;
483
+ if (leaked)
484
+ findings.push(relPath.replace(/\\/g, '/'));
485
+ }
486
+ return findings;
487
+ }
488
+ /** Read at most `maxBytes` from the start of a file, decoded as UTF-8. */
489
+ function readFilePrefix(absPath, maxBytes) {
490
+ const fd = openSync(absPath, 'r');
491
+ try {
492
+ const buffer = Buffer.allocUnsafe(maxBytes);
493
+ const bytesRead = readSync(fd, buffer, 0, maxBytes, 0);
494
+ return buffer.toString('utf8', 0, bytesRead);
495
+ }
496
+ finally {
497
+ closeSync(fd);
498
+ }
499
+ }
500
+ function collectSecretScanFiles(cwd, paths) {
501
+ const resolvedCwd = resolve(cwd);
502
+ const files = [];
503
+ const seenFiles = new Set();
504
+ let visitedDirectories = 0;
505
+ const isInsideRepo = (path) => path === resolvedCwd || path.startsWith(`${resolvedCwd}/`);
506
+ const addFile = (path) => {
507
+ if (files.length >= MAX_SCANNED_FILES || !isInsideRepo(path))
508
+ return;
509
+ const relPath = relative(resolvedCwd, path).replace(/\\/g, '/');
510
+ if (!relPath || seenFiles.has(relPath))
511
+ return;
512
+ seenFiles.add(relPath);
513
+ files.push(relPath);
514
+ };
515
+ const addEnvFiles = (dir) => {
516
+ if (files.length >= MAX_SCANNED_FILES || !isInsideRepo(dir))
517
+ return;
518
+ let entries;
519
+ try {
520
+ entries = readdirSync(dir, { withFileTypes: true });
521
+ }
522
+ catch {
523
+ return;
524
+ }
525
+ for (const entry of entries) {
526
+ if (entry.isFile() && (entry.name === '.env' || entry.name.startsWith('.env.'))) {
527
+ addFile(join(dir, entry.name));
528
+ }
529
+ }
530
+ };
531
+ const addDirectoryFiles = (rootDir) => {
532
+ const stack = [rootDir];
533
+ while (stack.length > 0 &&
534
+ files.length < MAX_SCANNED_FILES &&
535
+ visitedDirectories < MAX_SCAN_DIRECTORIES) {
536
+ const dir = stack.pop();
537
+ if (!dir || !isInsideRepo(dir))
538
+ continue;
539
+ visitedDirectories += 1;
540
+ let entries;
541
+ try {
542
+ entries = readdirSync(dir, { withFileTypes: true });
543
+ }
544
+ catch {
545
+ continue;
546
+ }
547
+ for (const entry of entries) {
548
+ if (files.length >= MAX_SCANNED_FILES)
549
+ break;
550
+ const path = join(dir, entry.name);
551
+ if (entry.isDirectory())
552
+ stack.push(path);
553
+ else if (entry.isFile())
554
+ addFile(path);
555
+ }
556
+ }
557
+ };
558
+ // Gitignored env files do not appear in porcelain output. Always inspect the
559
+ // repo root and candidate parent directories before ordinary candidates.
560
+ addEnvFiles(resolvedCwd);
561
+ const candidates = uniquePaths(paths).slice(0, MAX_SCANNED_FILES);
562
+ for (const relPath of candidates) {
563
+ if (files.length >= MAX_SCANNED_FILES)
564
+ break;
565
+ const absPath = resolve(cwd, relPath);
566
+ if (!isInsideRepo(absPath))
567
+ continue;
568
+ addEnvFiles(dirname(absPath));
569
+ if (!existsSync(absPath))
570
+ continue;
571
+ let stat;
572
+ try {
573
+ stat = statSync(absPath);
574
+ }
575
+ catch {
576
+ continue;
577
+ }
578
+ if (stat.isFile())
579
+ addFile(absPath);
580
+ else if (stat.isDirectory() && relPath.replace(/\\/g, '/').endsWith('/')) {
581
+ addDirectoryFiles(absPath);
582
+ }
583
+ }
584
+ return files;
585
+ }
586
+ async function invokeAgent(ctx, prompt) {
587
+ const env = { ...process.env };
588
+ if (ctx.apiKey)
589
+ env.MODA_API_KEY = ctx.apiKey;
590
+ if (ctx.ingestUrl)
591
+ env.MODA_INGEST_URL = ctx.ingestUrl;
592
+ return ctx.runAgent({
593
+ adapter: ctx.adapter,
74
594
  prompt,
75
- rootDir: cwd,
595
+ rootDir: ctx.cwd,
76
596
  mode: 'write',
77
- stream: human || Boolean(options.onEvent),
597
+ env,
598
+ stream: ctx.human || Boolean(ctx.onEvent),
78
599
  progressToStderr: false,
79
- onProgress: (message) => emit(message),
600
+ onProgress: (message) => ctx.emit(message),
80
601
  onAgentEvent: (event) => {
81
- options.onEvent?.(event);
602
+ ctx.onEvent?.(event);
82
603
  const card = describeCodingAgentEvent(event);
83
604
  if (card)
84
- emit(card);
605
+ ctx.emit(card);
85
606
  },
86
607
  });
608
+ }
609
+ /**
610
+ * Extract the agent result; on a protocol violation, re-prompt once for the
611
+ * result JSON. The protocol retry has its OWN single-use budget (it is a
612
+ * clarification, not a repair) and never consumes repair attempts — total
613
+ * agent invocations stay bounded at 1 initial + 1 protocol retry +
614
+ * maxRepairAttempts repairs.
615
+ */
616
+ async function extractResultWithProtocolRetry(ctx, run, protocolRetryUsed, onProtocolRetry) {
87
617
  if (run.failedToStart) {
88
- return { status: 'failed', adapter, detail: `${codingAgentDisplayName(adapter)} CLI failed to start.` };
618
+ return { agentFailed: { status: 'agent_failed', detail: 'CLI failed to start' } };
89
619
  }
90
620
  if (run.timedOut) {
91
- return { status: 'failed', adapter, detail: `${codingAgentDisplayName(adapter)} timed out during SDK integration.` };
621
+ return { agentFailed: { status: 'agent_failed', detail: 'timed out' } };
92
622
  }
93
- if (run.status !== 0) {
94
- const tail = `${run.stdout}\n${run.stderr}`.trim().slice(-800);
623
+ let extraction = extractSdkAgentResult({ cwd: ctx.cwd, stdout: run.stdout, stderr: run.stderr });
624
+ if ('error' in extraction && !protocolRetryUsed) {
625
+ onProtocolRetry();
626
+ ctx.emit('Agent did not emit a valid result; requesting protocol retry…');
627
+ const retryRun = await invokeAgent(ctx, renderProtocolRetryPrompt(ctx.catalog.relativeDir));
628
+ if (retryRun.failedToStart) {
629
+ return { agentFailed: { status: 'agent_failed', detail: 'CLI failed to start' } };
630
+ }
631
+ if (retryRun.timedOut) {
632
+ return { agentFailed: { status: 'agent_failed', detail: 'timed out' } };
633
+ }
634
+ extraction = extractSdkAgentResult({
635
+ cwd: ctx.cwd,
636
+ stdout: retryRun.stdout,
637
+ stderr: retryRun.stderr,
638
+ });
639
+ }
640
+ if ('error' in extraction) {
641
+ const tail = redactOutputTail(`${run.stdout}\n${run.stderr}`, ctx.apiKey);
642
+ const exitNote = run.status !== 0 ? ` exited with status ${run.status ?? 'unknown'}.` : '.';
95
643
  return {
96
- status: 'failed',
97
- adapter,
98
- detail: `${codingAgentDisplayName(adapter)} exited with status ${run.status ?? 'unknown'}.${tail ? `\n${tail}` : ''}`,
644
+ agentFailed: {
645
+ status: 'agent_failed',
646
+ detail: `SDK integration result protocol violation${exitNote}${tail ? `\n${tail}` : ''}`,
647
+ },
99
648
  };
100
649
  }
101
- return { status: 'integrated', adapter, skillId: installedSkillId };
650
+ return { result: extraction.result };
102
651
  }
103
- const SAFETY_RULES = [
104
- 'Scope and safety rules:',
105
- '- Only touch this repository (the working directory you were launched in).',
106
- '- Install the Moda SDK with the package manager this repo already uses.',
107
- '- Instrument the real LLM call sites; do not invent files or frameworks that are not here.',
108
- '- Never write MODA_API_KEY (or any secret) into source files, and never create or edit',
109
- ' .env files. The key belongs in the environment / a secret manager at runtime.',
110
- '- Make the smallest correct change. Match the existing code style and imports.',
111
- '- If something is ambiguous or the repo has no LLM calls to instrument, stop and explain',
112
- ' rather than guessing.',
113
- ];
114
- export function renderSdkIntegrationPrompt(context, skill = {}) {
115
- const header = [
116
- 'You are running inside `moda init` as a one-shot SDK integration task. The user has',
117
- 'approved letting you install packages and edit source files in THIS repository to add',
118
- 'Moda LLM observability. Do the integration now — do not just describe it.',
119
- '',
120
- ...SAFETY_RULES,
121
- '',
122
- renderDetectionBlock(context),
123
- '',
124
- ];
125
- const body = renderPromptBody(context, skill);
126
- const footer = [
652
+ function renderProtocolRetryPrompt(catalogDir) {
653
+ return [
654
+ 'Your previous run did not emit a valid SDK integration result.',
655
+ 'Write `.moda/sdk-integration-result.json` AND print the same JSON between',
656
+ `${SDK_RESULT_JSON_BEGIN} and ${SDK_RESULT_JSON_END} as your last output.`,
657
+ `Do not modify anything under ${catalogDir}.`,
127
658
  '',
128
- 'When finished, print a short summary of: the package(s) installed, the files you edited,',
129
- 'and any step a human still needs to do (e.g. setting MODA_API_KEY in the deploy env).',
130
- ];
131
- return [...header, ...body, ...footer].join('\n');
659
+ renderResultProtocolSection(),
660
+ ].join('\n');
661
+ }
662
+ function removeAgentResultFile(cwd) {
663
+ const path = join(cwd, SDK_RESULT_FILE);
664
+ try {
665
+ if (existsSync(path))
666
+ rmSync(path);
667
+ }
668
+ catch {
669
+ // Best-effort cleanup before CLI rewrites the final artifact.
670
+ }
671
+ }
672
+ async function mapAgentResultToOutcome(ctx, result) {
673
+ const agentDetail = (detail) => redactSecretText(detail, ctx.apiKey);
674
+ switch (result.status) {
675
+ case 'needs_human_input':
676
+ return {
677
+ status: 'needs_human_input',
678
+ detail: agentDetail(result.failure?.detail ?? result.summary),
679
+ };
680
+ case 'nothing_to_integrate': {
681
+ if (!result.smoke) {
682
+ return {
683
+ status: 'nothing_to_integrate',
684
+ detail: 'agent reports Moda is already integrated — not independently verified',
685
+ };
686
+ }
687
+ const verification = await verifyAgentSmoke(ctx);
688
+ if (verification.observed) {
689
+ return {
690
+ status: 'nothing_to_integrate',
691
+ detail: `existing integration verified — trace reached Moda (${verification.llm_spans} llm span(s))`,
692
+ verification,
693
+ };
694
+ }
695
+ return {
696
+ status: 'nothing_to_integrate',
697
+ detail: 'agent reports Moda is already integrated — verification trace was not observed',
698
+ verification,
699
+ };
700
+ }
701
+ case 'repository_checks_failed':
702
+ return {
703
+ status: 'repository_checks_failed',
704
+ detail: agentDetail(result.failure?.detail ?? result.summary),
705
+ repairable: 'repository_checks_failed',
706
+ };
707
+ case 'smoke_execution_failed':
708
+ return {
709
+ status: 'smoke_execution_failed',
710
+ detail: agentDetail(result.failure?.detail ?? result.summary),
711
+ repairable: 'smoke_execution_failed',
712
+ };
713
+ case 'failed':
714
+ return {
715
+ status: 'agent_failed',
716
+ detail: agentDetail(result.failure?.detail ?? result.summary),
717
+ repairable: 'failed',
718
+ };
719
+ case 'integrated': {
720
+ const verification = await verifyAgentSmoke(ctx);
721
+ if (verification.observed) {
722
+ let detail = `trace reached Moda (${verification.llm_spans} llm span(s))`;
723
+ if (verification.duplicate_count > 0) {
724
+ detail += `; warning: duplicate_count=${verification.duplicate_count}`;
725
+ }
726
+ return {
727
+ status: 'integrated_verified',
728
+ detail,
729
+ verification,
730
+ };
731
+ }
732
+ return {
733
+ status: 'telemetry_not_observed',
734
+ detail: 'integration applied but the verification trace was not observed',
735
+ verification,
736
+ repairable: 'telemetry_not_observed',
737
+ };
738
+ }
739
+ default:
740
+ return { status: 'agent_failed', detail: agentDetail(result.summary) };
741
+ }
132
742
  }
133
743
  /**
134
- * Four-tier body, most-specific first:
135
- * 1. skill materialized on disk → point the agent at it;
136
- * 2. skill resolved but not materialized → inline its content so a detected
137
- * stack never degrades to the generic checklist;
138
- * 3. skill resolved but its content is unavailable (bundle absent/unreadable)
139
- * → generic checklist, but flagged as a degraded fallback and pinned to the
140
- * detected stack so it is not silently treated as stack-less;
141
- * 4. nothing resolved (no language detected) → plain generic checklist.
744
+ * Independent exact-trace verification: the CLI polls the Data API for the
745
+ * CLI-minted verification id and requires >=1 llm span. Agent claims are never
746
+ * trusted as proof.
747
+ *
748
+ * ACCEPTED RISK (documented, not mitigated): the agent holds the tenant's
749
+ * MODA_API_KEY/MODA_INGEST_URL in its environment for the smoke run, so a
750
+ * deliberately deceptive agent could hand-craft an OTLP payload carrying the
751
+ * verification id + an llm span instead of exercising the instrumented app.
752
+ * The user's own coding agent operating on their own tenant is trusted not to
753
+ * be adversarial — the guards here target agent laziness, dishonest
754
+ * self-reports, and honest mistakes, not a malicious agent with the key. The
755
+ * same env access also means a prompt-injected agent could exfiltrate the key;
756
+ * the user approves this tradeoff in the plan step, and the deterministic
757
+ * secret scan + redaction limit accidental persistence/echoes.
142
758
  */
143
- function renderPromptBody(context, skill) {
144
- if (skill.installedSkillId) {
145
- const id = skill.installedSkillId;
146
- return [
147
- `Read \`.claude/skills/${id}/SKILL.md\` and follow its steps exactly, including its`,
148
- `stop conditions. \`EXAMPLE.md\` in the same directory shows the finished integration —`,
149
- 'match its structure.',
150
- ];
151
- }
152
- if (skill.inlineSkill) {
153
- return [
154
- `The bundled integration skill \`${skill.inlineSkill.skillId}\` matches this repo's stack,`,
155
- 'but could not be written to disk, so its full content is inlined below. Follow its steps',
156
- 'exactly, including its stop conditions:',
157
- '',
158
- '---',
159
- '',
160
- stripSkillFrontmatter(skill.inlineSkill.markdown).trim(),
161
- ];
759
+ async function verifyAgentSmoke(ctx) {
760
+ ctx.onPhase?.('verifying', 'Verifying trace in Moda…');
761
+ ctx.emit('Verifying trace in Moda…');
762
+ const evidence = await ctx.verify({
763
+ verificationId: ctx.verificationId,
764
+ timeoutMs: ctx.verifyTimeoutMs,
765
+ fetchAudit: ctx.fetchAudit,
766
+ });
767
+ // last_error carries arbitrary error text (the one evidence field that can),
768
+ // and the evidence lands verbatim in the outcome artifact and repair prompts.
769
+ return evidence.last_error !== undefined
770
+ ? { ...evidence, last_error: redactSecretText(evidence.last_error, ctx.apiKey) }
771
+ : evidence;
772
+ }
773
+ function installUsedSkills(result, catalog, cwd, emit) {
774
+ const validIds = new Set(catalog.index.skills.map((skill) => skill.id));
775
+ const installed = [];
776
+ const warnings = [];
777
+ for (const skillId of result.skills_used) {
778
+ if (!validIds.has(skillId)) {
779
+ warnings.push(`unknown skill id ${skillId}`);
780
+ continue;
781
+ }
782
+ try {
783
+ installIntegrationSkill(skillId, cwd);
784
+ installed.push(skillId);
785
+ emit(`Installed integration skill ${skillId} to .claude/skills/${skillId}/`);
786
+ }
787
+ catch (error) {
788
+ const message = error instanceof Error ? error.message : String(error);
789
+ warnings.push(`could not install ${skillId}: ${message}`);
790
+ }
162
791
  }
163
- // Tiers 3 & 4 share one renderer: renderSdkIntegrationGuide flags the degraded
164
- // fallback (and pins to the detection block) when a skill resolved but its
165
- // content was unavailable, and renders the plain generic checklist otherwise.
166
- return [
167
- 'Follow this Moda-authored integration checklist:',
168
- '',
169
- renderSdkIntegrationGuide(context, skill.resolvedSkillId),
170
- ];
792
+ if (warnings.length > 0) {
793
+ emit(warnings.join('; '));
794
+ }
795
+ return installed;
796
+ }
797
+ function composeFinalDetail(status, detail, verification, apiKey, guardsDegraded) {
798
+ const baseDetail = detail ?? (status === 'integrated_verified' && verification
799
+ ? `verified — trace reached Moda (${verification.llm_spans} llm span(s))`
800
+ : undefined);
801
+ const combined = guardsDegraded
802
+ ? [baseDetail, DEGRADED_GUARDS_NOTE].filter(Boolean).join('; ')
803
+ : baseDetail;
804
+ return combined ? redactSecretText(combined, apiKey) : undefined;
805
+ }
806
+ function writeFinalOutcomeArtifact(cwd, payload) {
807
+ const path = join(cwd, SDK_RESULT_FILE);
808
+ mkdirSync(dirname(path), { recursive: true });
809
+ writeFileSync(path, `${JSON.stringify({
810
+ schema_version: OUTCOME_SCHEMA_VERSION,
811
+ status: payload.status,
812
+ verification_id: payload.verification_id,
813
+ verification: payload.verification,
814
+ agent_result: payload.agent_result,
815
+ repair_attempts: payload.repair_attempts,
816
+ ...(payload.security_findings ? { security_findings: payload.security_findings } : {}),
817
+ finished_at: new Date().toISOString(),
818
+ }, null, 2)}\n`);
819
+ }
820
+ export function redactSecretText(text, apiKey) {
821
+ let redacted = text;
822
+ for (const secret of [apiKey, process.env.MODA_API_KEY]) {
823
+ if (secret)
824
+ redacted = redacted.split(secret).join('[REDACTED]');
825
+ }
826
+ SECRET_KEY_PATTERN.lastIndex = 0;
827
+ return redacted.replace(SECRET_KEY_PATTERN, '[REDACTED]');
828
+ }
829
+ function sanitizeAgentResult(result, apiKey) {
830
+ return JSON.parse(redactSecretText(JSON.stringify(result), apiKey));
831
+ }
832
+ function redactOutputTail(output, apiKey) {
833
+ return redactSecretText(output, apiKey).trim().slice(-800);
171
834
  }
172
835
  //# sourceMappingURL=sdk-integration.js.map