yam-flow 2.7.0 → 2.8.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.
Files changed (40) hide show
  1. package/AGENTS.md +1 -0
  2. package/CHANGELOG.md +29 -0
  3. package/COMMANDS.md +42 -5
  4. package/README.md +40 -8
  5. package/ROADMAP.md +17 -6
  6. package/dist/bin/yam.js +441 -70
  7. package/dist/lib/external-updates.d.ts +9 -0
  8. package/dist/lib/external-updates.js +232 -4
  9. package/dist/lib/next-step.d.ts +61 -0
  10. package/dist/lib/next-step.js +340 -0
  11. package/dist/lib/release-auth-readiness.d.ts +81 -0
  12. package/dist/lib/release-auth-readiness.js +106 -0
  13. package/dist/lib/release-registry-status.d.ts +18 -0
  14. package/dist/lib/release-registry-status.js +37 -0
  15. package/dist/lib/scout-receipt.d.ts +89 -0
  16. package/dist/lib/scout-receipt.js +542 -0
  17. package/dist/lib/skill-installation.d.ts +30 -0
  18. package/dist/lib/skill-installation.js +470 -82
  19. package/dist/lib/trust-kernel.d.ts +2 -0
  20. package/dist/lib/trust-kernel.js +21 -2
  21. package/package.json +22 -3
  22. package/references/final-report.md +15 -1
  23. package/references/next-step.md +67 -0
  24. package/references/scout-wide-scan.md +16 -0
  25. package/references/study-note.md +4 -2
  26. package/references/tool-trust-layer.md +2 -0
  27. package/skills/deep/SKILL.md +2 -0
  28. package/skills/mission/SKILL.md +2 -0
  29. package/skills/quick/SKILL.md +1 -0
  30. package/skills/scout/SKILL.md +19 -9
  31. package/skills/ueye/SKILL.md +1 -0
  32. package/src/bin/yam.ts +445 -68
  33. package/src/lib/external-updates.ts +262 -4
  34. package/src/lib/next-step.ts +397 -0
  35. package/src/lib/release-auth-readiness.ts +148 -0
  36. package/src/lib/release-registry-status.ts +55 -0
  37. package/src/lib/scout-receipt.ts +593 -0
  38. package/src/lib/skill-installation.ts +542 -87
  39. package/src/lib/trust-kernel.ts +25 -2
  40. package/yam.manifest.json +4 -3
@@ -0,0 +1,542 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants as fsConstants } from 'node:fs';
3
+ import fsp from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { compareStableText } from './stable-order.js';
6
+ const MAX_RECEIPT_BYTES = 1024 * 1024;
7
+ const MAX_SOURCES = 64;
8
+ const MAX_CLAIMS = 128;
9
+ const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,95}$/;
10
+ const URL_CREDENTIAL_KEYS = new Set([
11
+ 'accesstoken', 'apikey', 'auth', 'authtoken', 'authorization', 'awsaccesskeyid',
12
+ 'clientsecret', 'code', 'credential', 'googleaccessid', 'jwt', 'keypairid',
13
+ 'password', 'passwd', 'secret', 'securitytoken', 'session', 'sessionid', 'sig',
14
+ 'signature', 'token', 'xamzcredential', 'xamzsecuritytoken', 'xamzsignature',
15
+ 'xgoogcredential', 'xgoogsignature'
16
+ ]);
17
+ const URL_CREDENTIAL_VALUE_PATTERNS = [
18
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/,
19
+ /\bnpm_[A-Za-z0-9]{20,}\b/,
20
+ /\bsk-[A-Za-z0-9_-]{20,}\b/,
21
+ /\bAKIA[A-Z0-9]{16}\b/,
22
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/
23
+ ];
24
+ const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/;
25
+ const FAILURE_KINDS = [
26
+ 'not_measured',
27
+ 'no_results',
28
+ 'blocked_waf',
29
+ 'rate_limited',
30
+ 'auth_required',
31
+ 'paywall',
32
+ 'network_failed',
33
+ 'empty_content'
34
+ ];
35
+ export async function createScoutReceipt(input) {
36
+ assertExactKeys(input, ['root', 'receipt_path', 'spec', 'now'], 'scout receipt input', ['now']);
37
+ const root = await canonicalRoot(input.root);
38
+ const receiptPath = await resolveReceiptPath(root, input.receipt_path);
39
+ const spec = normalizeSpec(input.spec);
40
+ const canonical = {
41
+ schema: 'yam.scout-receipt.v1',
42
+ created_at: (input.now || (() => new Date()))().toISOString(),
43
+ ...spec,
44
+ evidence_boundary: {
45
+ acquisition_by_yam: false,
46
+ source_interpretation: 'operator_supplied',
47
+ external_content_trusted_as_instruction: false
48
+ },
49
+ truth_status: 'partial'
50
+ };
51
+ const receipt = { ...canonical, digest: digest(canonical) };
52
+ await writeImmutableJson(root, receiptPath, receipt);
53
+ return {
54
+ schema: 'yam.scout-receipt-write.v1',
55
+ receipt_path: receiptPath,
56
+ receipt,
57
+ truth_status: 'partial',
58
+ next_action: 'retain the immutable receipt and use its digest as the baseline for the next delta scan'
59
+ };
60
+ }
61
+ export function verifyScoutReceipt(value) {
62
+ const errors = [];
63
+ const record = isObject(value) ? value : {};
64
+ assertRecordKeys(record, [
65
+ 'schema', 'created_at', 'subject', 'clocks', 'sources', 'claims', 'acquisition_failures',
66
+ 'opposition', 'recommendation', 'stop_reason', 'evidence_boundary', 'truth_status', 'digest'
67
+ ], errors, 'receipt');
68
+ if (record.schema !== 'yam.scout-receipt.v1')
69
+ errors.push('schema_invalid');
70
+ if (!isIsoDate(record.created_at))
71
+ errors.push('created_at_invalid');
72
+ let normalized = null;
73
+ try {
74
+ normalized = normalizeSpec({
75
+ subject: record.subject,
76
+ clocks: record.clocks,
77
+ sources: record.sources,
78
+ claims: record.claims,
79
+ acquisition_failures: record.acquisition_failures,
80
+ opposition: record.opposition,
81
+ recommendation: record.recommendation,
82
+ stop_reason: record.stop_reason
83
+ });
84
+ }
85
+ catch (error) {
86
+ errors.push(`contract_invalid:${message(error)}`);
87
+ }
88
+ if (normalized) {
89
+ for (const key of ['subject', 'clocks', 'sources', 'claims', 'acquisition_failures', 'opposition', 'recommendation', 'stop_reason']) {
90
+ if (JSON.stringify(record[key]) !== JSON.stringify(normalized[key])) {
91
+ errors.push(`${key}_not_canonical`);
92
+ }
93
+ }
94
+ }
95
+ const boundary = isObject(record.evidence_boundary) ? record.evidence_boundary : {};
96
+ assertRecordKeys(boundary, ['acquisition_by_yam', 'source_interpretation', 'external_content_trusted_as_instruction'], errors, 'evidence_boundary');
97
+ if (boundary.acquisition_by_yam !== false)
98
+ errors.push('acquisition_boundary_invalid');
99
+ if (boundary.source_interpretation !== 'operator_supplied')
100
+ errors.push('interpretation_boundary_invalid');
101
+ if (boundary.external_content_trusted_as_instruction !== false)
102
+ errors.push('external_instruction_boundary_invalid');
103
+ if (record.truth_status !== 'partial')
104
+ errors.push('truth_status_invalid');
105
+ const canonical = { ...record };
106
+ delete canonical.digest;
107
+ if (typeof record.digest !== 'string' || record.digest !== digest(canonical))
108
+ errors.push('digest_invalid');
109
+ const uniqueErrors = [...new Set(errors)];
110
+ return {
111
+ schema: 'yam.scout-receipt-verification.v1',
112
+ valid: uniqueErrors.length === 0,
113
+ errors: uniqueErrors,
114
+ receipt_truth_status: record.truth_status || 'blocked',
115
+ truth_status: uniqueErrors.length ? 'blocked' : 'verified',
116
+ next_action: uniqueErrors.length
117
+ ? `reject the Scout receipt and repair: ${uniqueErrors[0]}`
118
+ : 'receipt structure, source links, canonical ordering, and digest are intact; source interpretation remains operator-supplied'
119
+ };
120
+ }
121
+ export async function verifyScoutReceiptFile(input) {
122
+ assertExactKeys(input, ['root', 'receipt_path'], 'scout receipt verification input');
123
+ const root = await canonicalRoot(input.root);
124
+ const receiptPath = await resolveReceiptPath(root, input.receipt_path);
125
+ const value = await readBoundedJson(root, receiptPath);
126
+ return { ...verifyScoutReceipt(value), receipt_path: receiptPath };
127
+ }
128
+ function normalizeSpec(value) {
129
+ if (!isObject(value))
130
+ throw new Error('spec must be an object');
131
+ assertExactKeys(value, [
132
+ 'subject', 'clocks', 'sources', 'claims', 'acquisition_failures',
133
+ 'opposition', 'recommendation', 'stop_reason'
134
+ ], 'scout receipt spec');
135
+ const subject = exactObject(value.subject, ['canonical_name', 'aliases', 'canonical_url'], 'subject');
136
+ const canonicalName = boundedText(subject.canonical_name, 'subject.canonical_name', 200);
137
+ const canonicalUrl = httpUrl(subject.canonical_url, 'subject.canonical_url');
138
+ const aliases = [...new Set(stringArray(subject.aliases, 'subject.aliases', 32, 200))].sort(compareStableText);
139
+ const clocks = exactObject(value.clocks, ['registry_latest', 'release_tag', 'main_version', 'latest_commit', 'stability'], 'clocks');
140
+ const stability = enumValue(clocks.stability, ['stable', 'release_candidate', 'unreleased', 'mixed', 'unknown'], 'clocks.stability');
141
+ const normalizedClocks = {
142
+ registry_latest: optionalText(clocks.registry_latest, 'clocks.registry_latest', 160),
143
+ release_tag: optionalText(clocks.release_tag, 'clocks.release_tag', 160),
144
+ main_version: optionalText(clocks.main_version, 'clocks.main_version', 160),
145
+ latest_commit: optionalText(clocks.latest_commit, 'clocks.latest_commit', 160)
146
+ };
147
+ if (stability !== 'unknown' && Object.values(normalizedClocks).some((clock) => !clock)) {
148
+ throw new Error('all four version clocks are required when clocks.stability is not unknown');
149
+ }
150
+ if (!Array.isArray(value.sources) || value.sources.length === 0 || value.sources.length > MAX_SOURCES) {
151
+ throw new Error(`sources must contain 1-${MAX_SOURCES} items`);
152
+ }
153
+ const sources = value.sources.map((item, index) => normalizeSource(item, index)).sort((a, b) => compareStableText(a.id, b.id));
154
+ const sourceIds = new Set(sources.map((item) => item.id));
155
+ if (sourceIds.size !== sources.length)
156
+ throw new Error('source ids must be unique');
157
+ if (!Array.isArray(value.claims) || value.claims.length === 0 || value.claims.length > MAX_CLAIMS) {
158
+ throw new Error(`claims must contain 1-${MAX_CLAIMS} items`);
159
+ }
160
+ const claims = value.claims.map((item, index) => normalizeClaim(item, index, sourceIds)).sort((a, b) => compareStableText(a.id, b.id));
161
+ if (new Set(claims.map((item) => item.id)).size !== claims.length)
162
+ throw new Error('claim ids must be unique');
163
+ if (!Array.isArray(value.acquisition_failures) || value.acquisition_failures.length > MAX_SOURCES) {
164
+ throw new Error(`acquisition_failures must be an array with at most ${MAX_SOURCES} items`);
165
+ }
166
+ const acquisitionFailures = value.acquisition_failures.map((item, index) => {
167
+ const record = exactObject(item, ['source_id', 'kind', 'note'], `acquisition_failures[${index}]`);
168
+ const sourceId = idValue(record.source_id, `acquisition_failures[${index}].source_id`);
169
+ return {
170
+ source_id: sourceId,
171
+ kind: enumValue(record.kind, FAILURE_KINDS, `acquisition_failures[${index}].kind`),
172
+ note: boundedText(record.note, `acquisition_failures[${index}].note`, 600)
173
+ };
174
+ }).sort((a, b) => compareStableText(`${a.source_id}:${a.kind}`, `${b.source_id}:${b.kind}`));
175
+ return {
176
+ subject: { canonical_name: canonicalName, aliases, canonical_url: canonicalUrl },
177
+ clocks: {
178
+ ...normalizedClocks,
179
+ stability: stability
180
+ },
181
+ sources,
182
+ claims,
183
+ acquisition_failures: acquisitionFailures,
184
+ opposition: boundedText(value.opposition, 'opposition', 2000),
185
+ recommendation: boundedText(value.recommendation, 'recommendation', 2000),
186
+ stop_reason: boundedText(value.stop_reason, 'stop_reason', 1000)
187
+ };
188
+ }
189
+ function normalizeSource(value, index) {
190
+ const label = `sources[${index}]`;
191
+ const record = exactObject(value, [
192
+ 'id', 'canonical_url', 'access_path', 'source_class', 'retrieved_at', 'version', 'revision',
193
+ 'content_digest', 'authority', 'freshness', 'directness'
194
+ ], label);
195
+ const retrievedAt = exactString(record.retrieved_at, `${label}.retrieved_at`);
196
+ if (!isIsoDate(retrievedAt))
197
+ throw new Error(`${label}.retrieved_at must be an ISO timestamp`);
198
+ const contentDigest = exactString(record.content_digest, `${label}.content_digest`);
199
+ if (!SHA256_PATTERN.test(contentDigest))
200
+ throw new Error(`${label}.content_digest must be sha256:<64 lowercase hex>`);
201
+ return {
202
+ id: idValue(record.id, `${label}.id`),
203
+ canonical_url: httpUrl(record.canonical_url, `${label}.canonical_url`),
204
+ access_path: boundedText(record.access_path, `${label}.access_path`, 300),
205
+ source_class: enumValue(record.source_class, ['official', 'implementation', 'third_party', 'community', 'contrarian', 'local'], `${label}.source_class`),
206
+ retrieved_at: retrievedAt,
207
+ version: optionalText(record.version, `${label}.version`, 160),
208
+ revision: optionalText(record.revision, `${label}.revision`, 160),
209
+ content_digest: contentDigest,
210
+ authority: enumValue(record.authority, ['high', 'medium', 'low'], `${label}.authority`),
211
+ freshness: enumValue(record.freshness, ['high', 'medium', 'low'], `${label}.freshness`),
212
+ directness: enumValue(record.directness, ['high', 'medium', 'low'], `${label}.directness`)
213
+ };
214
+ }
215
+ function normalizeClaim(value, index, sourceIds) {
216
+ const label = `claims[${index}]`;
217
+ const record = exactObject(value, ['id', 'text', 'source_ids', 'confidence', 'uncertainty', 'decision_impact'], label);
218
+ const claimSourceIds = stringArray(record.source_ids, `${label}.source_ids`, MAX_SOURCES, 96)
219
+ .map((item) => idValue(item, `${label}.source_ids`))
220
+ .sort(compareStableText);
221
+ if (claimSourceIds.length === 0)
222
+ throw new Error(`${label}.source_ids must not be empty`);
223
+ for (const sourceId of claimSourceIds) {
224
+ if (!sourceIds.has(sourceId))
225
+ throw new Error(`${label} references unknown source: ${sourceId}`);
226
+ }
227
+ return {
228
+ id: idValue(record.id, `${label}.id`),
229
+ text: boundedText(record.text, `${label}.text`, 2000),
230
+ source_ids: [...new Set(claimSourceIds)],
231
+ confidence: enumValue(record.confidence, ['high', 'medium', 'low'], `${label}.confidence`),
232
+ uncertainty: optionalText(record.uncertainty, `${label}.uncertainty`, 1000),
233
+ decision_impact: boundedText(record.decision_impact, `${label}.decision_impact`, 1000)
234
+ };
235
+ }
236
+ async function canonicalRoot(value) {
237
+ const resolved = path.resolve(value || '.');
238
+ const real = await fsp.realpath(resolved);
239
+ const stat = await fsp.lstat(real);
240
+ if (!stat.isDirectory() || stat.isSymbolicLink())
241
+ throw new Error(`root must be a regular physical directory: ${real}`);
242
+ return real;
243
+ }
244
+ async function resolveReceiptPath(root, value) {
245
+ const candidate = path.resolve(root, value);
246
+ if (!pathWithin(root, candidate))
247
+ throw new Error(`receipt path escapes root: ${candidate}`);
248
+ if (candidate === root)
249
+ throw new Error('receipt path must be a file below root');
250
+ return candidate;
251
+ }
252
+ async function writeImmutableJson(root, target, value) {
253
+ await ensureRegularDirectoryPath(root, path.dirname(target));
254
+ const parents = await captureRegularDirectoryPath(root, path.dirname(target), 'Scout receipt write');
255
+ const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
256
+ let handle;
257
+ try {
258
+ handle = await fsp.open(target, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, 0o600);
259
+ }
260
+ catch (error) {
261
+ if (error.code === 'EEXIST')
262
+ throw new Error(`receipt already exists and will not be overwritten: ${target}`);
263
+ throw error;
264
+ }
265
+ let targetCreated = false;
266
+ let writeFailure;
267
+ try {
268
+ const opened = await handle.stat();
269
+ targetCreated = opened.isFile();
270
+ await revalidateRegularDirectoryPath(parents, 'Scout receipt write');
271
+ const current = await fsp.lstat(target);
272
+ if (!opened.isFile()
273
+ || current.isSymbolicLink()
274
+ || !current.isFile()
275
+ || current.dev !== opened.dev
276
+ || current.ino !== opened.ino) {
277
+ throw new Error(`Scout receipt changed identity before writing: ${target}`);
278
+ }
279
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
280
+ await handle.sync();
281
+ const afterOpened = await handle.stat();
282
+ const after = await fsp.lstat(target);
283
+ await revalidateRegularDirectoryPath(parents, 'Scout receipt write');
284
+ if (after.isSymbolicLink()
285
+ || !after.isFile()
286
+ || after.dev !== opened.dev
287
+ || after.ino !== opened.ino
288
+ || afterOpened.dev !== opened.dev
289
+ || afterOpened.ino !== opened.ino) {
290
+ throw new Error(`Scout receipt changed identity while writing: ${target}`);
291
+ }
292
+ }
293
+ catch (error) {
294
+ writeFailure = error;
295
+ }
296
+ try {
297
+ await handle.close();
298
+ }
299
+ catch (error) {
300
+ writeFailure ||= error;
301
+ }
302
+ if (writeFailure) {
303
+ if (targetCreated) {
304
+ throw new AggregateError([writeFailure], `Scout receipt write failed; the possibly partial file was preserved for manual identity inspection because pathname cleanup cannot be proven safe: ${target}`);
305
+ }
306
+ throw writeFailure;
307
+ }
308
+ }
309
+ async function ensureRegularDirectoryPath(root, target) {
310
+ const relative = path.relative(root, target);
311
+ if (relative === '')
312
+ return;
313
+ if (relative.startsWith('..') || path.isAbsolute(relative))
314
+ throw new Error(`directory path escapes root: ${target}`);
315
+ let current = root;
316
+ for (const segment of relative.split(path.sep)) {
317
+ current = path.join(current, segment);
318
+ try {
319
+ const stat = await fsp.lstat(current);
320
+ if (!stat.isDirectory() || stat.isSymbolicLink())
321
+ throw new Error(`expected a regular directory path segment: ${current}`);
322
+ }
323
+ catch (error) {
324
+ if (error.code !== 'ENOENT')
325
+ throw error;
326
+ await fsp.mkdir(current, { mode: 0o700 });
327
+ const stat = await fsp.lstat(current);
328
+ if (!stat.isDirectory() || stat.isSymbolicLink())
329
+ throw new Error(`created directory changed identity: ${current}`);
330
+ }
331
+ }
332
+ }
333
+ async function readBoundedJson(root, target) {
334
+ const parents = await captureRegularDirectoryPath(root, path.dirname(target), 'Scout receipt read');
335
+ const before = await fsp.lstat(target);
336
+ if (before.isSymbolicLink() || !before.isFile()) {
337
+ throw new Error(`Scout receipt must be a regular non-symlink file: ${target}`);
338
+ }
339
+ if (before.size > MAX_RECEIPT_BYTES)
340
+ throw new Error(`Scout receipt exceeds ${MAX_RECEIPT_BYTES} bytes`);
341
+ const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
342
+ const handle = await fsp.open(target, fsConstants.O_RDONLY | noFollow);
343
+ try {
344
+ const opened = await handle.stat();
345
+ await revalidateRegularDirectoryPath(parents, 'Scout receipt read');
346
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) {
347
+ throw new Error(`Scout receipt changed identity while opening: ${target}`);
348
+ }
349
+ if (opened.size > MAX_RECEIPT_BYTES)
350
+ throw new Error(`Scout receipt exceeds ${MAX_RECEIPT_BYTES} bytes`);
351
+ const bytes = await readBoundedHandle(handle, MAX_RECEIPT_BYTES);
352
+ const after = await fsp.lstat(target);
353
+ const afterOpened = await handle.stat();
354
+ await revalidateRegularDirectoryPath(parents, 'Scout receipt read');
355
+ if (after.isSymbolicLink()
356
+ || !after.isFile()
357
+ || after.dev !== opened.dev
358
+ || after.ino !== opened.ino
359
+ || afterOpened.dev !== opened.dev
360
+ || afterOpened.ino !== opened.ino
361
+ || afterOpened.size !== opened.size
362
+ || bytes.length !== opened.size) {
363
+ throw new Error(`Scout receipt changed identity while reading: ${target}`);
364
+ }
365
+ return JSON.parse(bytes.toString('utf8'));
366
+ }
367
+ finally {
368
+ await handle.close();
369
+ }
370
+ }
371
+ async function readBoundedHandle(handle, maxBytes) {
372
+ const buffer = Buffer.alloc(maxBytes + 1);
373
+ let total = 0;
374
+ while (total < buffer.length) {
375
+ const { bytesRead } = await handle.read(buffer, total, buffer.length - total, null);
376
+ if (bytesRead === 0)
377
+ break;
378
+ total += bytesRead;
379
+ }
380
+ if (total > maxBytes)
381
+ throw new Error(`Scout receipt exceeds ${maxBytes} bytes while being read`);
382
+ return buffer.subarray(0, total);
383
+ }
384
+ async function captureRegularDirectoryPath(root, target, label) {
385
+ const relative = path.relative(root, target);
386
+ if (relative.startsWith('..') || path.isAbsolute(relative))
387
+ throw new Error(`${label} escapes root: ${target}`);
388
+ const identities = [];
389
+ let current = root;
390
+ for (const segment of relative ? ['', ...relative.split(path.sep)] : ['']) {
391
+ if (segment)
392
+ current = path.join(current, segment);
393
+ const stat = await fsp.lstat(current);
394
+ if (!stat.isDirectory() || stat.isSymbolicLink())
395
+ throw new Error(`${label} expected a regular directory: ${current}`);
396
+ identities.push({ path: current, dev: stat.dev, ino: stat.ino });
397
+ }
398
+ return identities;
399
+ }
400
+ async function revalidateRegularDirectoryPath(identities, label) {
401
+ for (const identity of identities) {
402
+ const stat = await fsp.lstat(identity.path);
403
+ if (!stat.isDirectory()
404
+ || stat.isSymbolicLink()
405
+ || stat.dev !== identity.dev
406
+ || stat.ino !== identity.ino) {
407
+ throw new Error(`${label} parent changed identity: ${identity.path}`);
408
+ }
409
+ }
410
+ }
411
+ function exactObject(value, keys, label) {
412
+ if (!isObject(value))
413
+ throw new Error(`${label} must be an object`);
414
+ assertExactKeys(value, keys, label);
415
+ return value;
416
+ }
417
+ function assertExactKeys(value, keys, label, optional = []) {
418
+ if (!isObject(value))
419
+ throw new Error(`${label} must be an object`);
420
+ const allowed = new Set(keys);
421
+ const required = keys.filter((key) => !optional.includes(key));
422
+ const extra = Object.keys(value).filter((key) => !allowed.has(key));
423
+ const missing = required.filter((key) => !(key in value));
424
+ if (extra.length || missing.length)
425
+ throw new Error(`${label} keys invalid; missing=[${missing.join(',')}], extra=[${extra.join(',')}]`);
426
+ }
427
+ function assertRecordKeys(value, keys, errors, label) {
428
+ const expected = new Set(keys);
429
+ for (const key of Object.keys(value))
430
+ if (!expected.has(key))
431
+ errors.push(`${label}_unexpected_key:${key}`);
432
+ for (const key of keys)
433
+ if (!(key in value))
434
+ errors.push(`${label}_missing_key:${key}`);
435
+ }
436
+ function stringArray(value, label, maxItems, maxLength) {
437
+ if (!Array.isArray(value) || value.length > maxItems)
438
+ throw new Error(`${label} must be an array with at most ${maxItems} items`);
439
+ return value.map((item, index) => boundedText(item, `${label}[${index}]`, maxLength));
440
+ }
441
+ function boundedText(value, label, maxLength) {
442
+ const text = exactString(value, label).trim();
443
+ if (!text)
444
+ throw new Error(`${label} must not be empty`);
445
+ if (text.length > maxLength)
446
+ throw new Error(`${label} exceeds ${maxLength} characters`);
447
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text))
448
+ throw new Error(`${label} contains control characters`);
449
+ return text;
450
+ }
451
+ function optionalText(value, label, maxLength) {
452
+ const text = exactString(value, label).trim();
453
+ if (text.length > maxLength)
454
+ throw new Error(`${label} exceeds ${maxLength} characters`);
455
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text))
456
+ throw new Error(`${label} contains control characters`);
457
+ return text;
458
+ }
459
+ function idValue(value, label) {
460
+ const text = exactString(value, label);
461
+ if (!ID_PATTERN.test(text))
462
+ throw new Error(`${label} must match ${ID_PATTERN}`);
463
+ return text;
464
+ }
465
+ function httpUrl(value, label) {
466
+ const text = boundedText(value, label, 2048);
467
+ let parsed;
468
+ try {
469
+ parsed = new URL(text);
470
+ }
471
+ catch {
472
+ throw new Error(`${label} must be an absolute URL`);
473
+ }
474
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
475
+ throw new Error(`${label} must use http or https`);
476
+ if (parsed.username || parsed.password)
477
+ throw new Error(`${label} must not contain URL credentials`);
478
+ if (urlSearchContainsCredentials(parsed.searchParams))
479
+ throw new Error(`${label} must not contain URL credentials`);
480
+ const fragment = parsed.hash.startsWith('#') ? parsed.hash.slice(1) : '';
481
+ if (urlTextContainsCredentials(parsed.pathname) || urlTextContainsCredentials(fragment)) {
482
+ throw new Error(`${label} must not contain URL credentials`);
483
+ }
484
+ if (fragment.includes('=') && urlSearchContainsCredentials(new URLSearchParams(fragment))) {
485
+ throw new Error(`${label} must not contain URL credentials`);
486
+ }
487
+ return parsed.toString();
488
+ }
489
+ function urlTextContainsCredentials(value) {
490
+ let decoded = value;
491
+ try {
492
+ decoded = decodeURIComponent(value);
493
+ }
494
+ catch {
495
+ // The URL parser already validated the URL. Scan the original text when percent decoding is malformed.
496
+ }
497
+ return URL_CREDENTIAL_VALUE_PATTERNS.some((pattern) => pattern.test(decoded));
498
+ }
499
+ function urlSearchContainsCredentials(params) {
500
+ for (const [rawKey, value] of params.entries()) {
501
+ const key = rawKey.toLowerCase().replace(/[^a-z0-9]/g, '');
502
+ if (URL_CREDENTIAL_KEYS.has(key))
503
+ return true;
504
+ if (urlTextContainsCredentials(value))
505
+ return true;
506
+ }
507
+ return false;
508
+ }
509
+ function enumValue(value, allowed, label) {
510
+ const text = exactString(value, label);
511
+ if (!allowed.includes(text))
512
+ throw new Error(`${label} must be one of ${allowed.join('|')}`);
513
+ return text;
514
+ }
515
+ function exactString(value, label) {
516
+ if (typeof value !== 'string')
517
+ throw new Error(`${label} must be a string`);
518
+ return value;
519
+ }
520
+ function pathWithin(root, target) {
521
+ const relative = path.relative(root, target);
522
+ return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
523
+ }
524
+ function digest(value) {
525
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
526
+ }
527
+ function isIsoDate(value) {
528
+ if (typeof value !== 'string' || Number.isNaN(Date.parse(value)))
529
+ return false;
530
+ try {
531
+ return new Date(value).toISOString() === value;
532
+ }
533
+ catch {
534
+ return false;
535
+ }
536
+ }
537
+ function isObject(value) {
538
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
539
+ }
540
+ function message(error) {
541
+ return error instanceof Error ? error.message : String(error);
542
+ }
@@ -72,6 +72,35 @@ export type TransactionalInstallResult = {
72
72
  sourceDigest: string;
73
73
  cleanupWarnings: string[];
74
74
  };
75
+ export type SkillInstallPlanOperation = {
76
+ action: 'create' | 'replace_managed' | 'replace_explicitly_authorized' | 'remove_retired' | 'write_receipt';
77
+ skill: string;
78
+ path: string;
79
+ reason: string;
80
+ };
81
+ export type SkillInstallPlan = {
82
+ schema: 'yam.install-plan.v1';
83
+ generated_at: string;
84
+ mutation_authorized: false;
85
+ package: {
86
+ name: string;
87
+ version: string;
88
+ };
89
+ source: {
90
+ root: string;
91
+ digest: string;
92
+ file_count: number;
93
+ };
94
+ destination: string;
95
+ receipt_path: string;
96
+ operations: SkillInstallPlanOperation[];
97
+ preserved_paths: string[];
98
+ blockers: string[];
99
+ ready: boolean;
100
+ truth_status: 'verified' | 'blocked';
101
+ plan_digest: string;
102
+ next_action: string;
103
+ };
75
104
  export type SafeUninstallOptions = {
76
105
  destination: string;
77
106
  codexMirror?: string;
@@ -89,5 +118,6 @@ export type SafeUninstallResult = {
89
118
  };
90
119
  export declare function findInstallRecoveryArtifacts(root: string): Promise<string[]>;
91
120
  export declare function inspectSkillInstallation(options: InstallationInspectionOptions): Promise<InstallationInspection>;
121
+ export declare function planSkillSetInstallation(options: TransactionalInstallOptions): Promise<SkillInstallPlan>;
92
122
  export declare function installSkillSetTransactional(options: TransactionalInstallOptions): Promise<TransactionalInstallResult>;
93
123
  export declare function uninstallSkillSetSafely(options: SafeUninstallOptions): Promise<SafeUninstallResult>;