@moda-ai/cli 1.20.1 → 1.22.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.
- package/README.md +3 -0
- package/dist/api-client.d.ts +1 -1
- package/dist/api-client.js +2 -2
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +109 -1
- package/dist/init/index.d.ts +2 -0
- package/dist/init/index.js +101 -26
- package/dist/init/sdk-catalog.d.ts +10 -0
- package/dist/init/sdk-catalog.js +80 -0
- package/dist/init/sdk-guide.d.ts +5 -34
- package/dist/init/sdk-guide.js +74 -290
- package/dist/init/sdk-integration.d.ts +43 -44
- package/dist/init/sdk-integration.js +796 -133
- package/dist/init/sdk-protocol.d.ts +135 -0
- package/dist/init/sdk-protocol.js +152 -0
- package/dist/init/sdk-verification.d.ts +27 -0
- package/dist/init/sdk-verification.js +123 -0
- package/dist/manifest.js +1 -0
- package/dist/output.d.ts +8 -0
- package/dist/output.js +11 -0
- package/dist/schemas.d.ts +34 -0
- package/dist/schemas.js +30 -0
- package/package.json +1 -1
- package/skills/integration/index.json +2 -2
- package/skills/moda-cli/SKILL.md +29 -1
- package/dist/init/skill-resolver.d.ts +0 -19
- package/dist/init/skill-resolver.js +0 -78
|
@@ -1,172 +1,835 @@
|
|
|
1
|
-
// src/init/sdk-integration.ts —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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: '
|
|
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.
|
|
33
|
+
const redacted = redactSecretText(message, options.apiKey);
|
|
34
|
+
options.onProgress?.(redacted);
|
|
35
35
|
if (human)
|
|
36
|
-
process.stderr.write(` ${codingAgentDisplayName(adapter)}: ${
|
|
36
|
+
process.stderr.write(` ${codingAgentDisplayName(adapter)}: ${redacted}\n`);
|
|
37
37
|
};
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
618
|
+
return { agentFailed: { status: 'agent_failed', detail: 'CLI failed to start' } };
|
|
89
619
|
}
|
|
90
620
|
if (run.timedOut) {
|
|
91
|
-
return { status: '
|
|
621
|
+
return { agentFailed: { status: 'agent_failed', detail: 'timed out' } };
|
|
92
622
|
}
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
644
|
+
agentFailed: {
|
|
645
|
+
status: 'agent_failed',
|
|
646
|
+
detail: `SDK integration result protocol violation${exitNote}${tail ? `\n${tail}` : ''}`,
|
|
647
|
+
},
|
|
99
648
|
};
|
|
100
649
|
}
|
|
101
|
-
return {
|
|
650
|
+
return { result: extraction.result };
|
|
102
651
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|