@vfarcic/dot-ai 1.22.0 → 1.23.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.
@@ -46,6 +46,9 @@ var __importStar = (this && this.__importStar) || (function () {
46
46
  };
47
47
  })();
48
48
  Object.defineProperty(exports, "__esModule", { value: true });
49
+ exports.UserPromptsOverrideError = exports.IngestedSourceNotFoundError = exports.PromptsSourceValidationError = exports.MAX_INGESTED_SOURCES = void 0;
50
+ exports.sanitizeIngestFileMode = sanitizeIngestFileMode;
51
+ exports.ingestPromptsSource = ingestPromptsSource;
49
52
  exports.getUserPromptsConfig = getUserPromptsConfig;
50
53
  exports.computePromptsSource = computePromptsSource;
51
54
  exports.getUserPromptsConfigFromOverride = getUserPromptsConfigFromOverride;
@@ -54,6 +57,8 @@ exports.sanitizeUrlForLogging = sanitizeUrlForLogging;
54
57
  exports.scrubSourceUrl = scrubSourceUrl;
55
58
  exports.loadUserPrompts = loadUserPrompts;
56
59
  exports.clearUserPromptsCache = clearUserPromptsCache;
60
+ exports.clearIngestedPromptsSources = clearIngestedPromptsSources;
61
+ exports.getIngestedPromptsSources = getIngestedPromptsSources;
57
62
  exports.getUserPromptsCacheState = getUserPromptsCacheState;
58
63
  const fs = __importStar(require("fs"));
59
64
  const path = __importStar(require("path"));
@@ -63,6 +68,393 @@ const git_utils_1 = require("./git-utils");
63
68
  const prompts_1 = require("../tools/prompts");
64
69
  // In-memory cache state (persists across requests within same process)
65
70
  let cacheState = null;
71
+ const ingestedSources = new Map();
72
+ /**
73
+ * PRD #647 D5 — upload-input hardening caps. These are the EXACT values the
74
+ * frozen contract (.dot-agent-deck/647-contract.md) and the integration suite
75
+ * pin, chosen to trip the app-level cap before the ~1 MiB nginx ingress limit.
76
+ */
77
+ const MAX_INGEST_FILES = 100;
78
+ const MAX_INGEST_TOTAL_BYTES = 256 * 1024; // 256 KiB
79
+ /**
80
+ * PRD #647 F5 / D2-M4 — max number of distinct ingested sources held in memory.
81
+ * The cache is push-populated by authenticated uploads, so without a bound it
82
+ * grows unbounded across distinct identifiers (a memory-growth vector). This
83
+ * is a simple access-ordered LRU cap: on overflow the least-recently-used entry
84
+ * (oldest `uploadedAt`, refreshed on every successful render) is evicted, and a
85
+ * later `?source=` render of an evicted identifier hits the existing D2
86
+ * render-miss guidance (re-upload required). Correctness over tuning per the
87
+ * frozen contract — 50 distinct sources is ample for the CLI's per-host usage.
88
+ */
89
+ exports.MAX_INGESTED_SOURCES = 50;
90
+ /**
91
+ * Thrown by ingestPromptsSource on a malformed/unsafe upload so the REST handler
92
+ * can map it to a 400 (vs. a 500 for unexpected IO failures).
93
+ */
94
+ class PromptsSourceValidationError extends Error {
95
+ constructor(message) {
96
+ super(message);
97
+ this.name = 'PromptsSourceValidationError';
98
+ }
99
+ }
100
+ exports.PromptsSourceValidationError = PromptsSourceValidationError;
101
+ /**
102
+ * PRD #647 D2 — thrown when a render names an ingested `?source=<identifier>`
103
+ * that is not (or no longer) cached. It carries actionable re-upload guidance
104
+ * and is mapped to a 400 VALIDATION_ERROR by the REST handler. Deliberately
105
+ * distinct from the generic "Prompt not found" so the caller knows to re-upload
106
+ * the source rather than wonder why a known skill name is missing — and it never
107
+ * triggers (nor mentions) a git clone, since ingested identifiers are never
108
+ * cloned.
109
+ */
110
+ class IngestedSourceNotFoundError extends Error {
111
+ constructor(message) {
112
+ super(message);
113
+ this.name = 'IngestedSourceNotFoundError';
114
+ }
115
+ }
116
+ exports.IngestedSourceNotFoundError = IngestedSourceNotFoundError;
117
+ /**
118
+ * PRD #647 D5 — sanitize an untrusted POSIX `mode` string from an uploaded
119
+ * manifest into a safe numeric file mode. Strips the special bits
120
+ * (setuid 04000, setgid 02000, sticky 01000) so an uploaded skill file can never
121
+ * carry an exec-escalation surprise, and keeps only the standard rwx permission
122
+ * bits (07777 → 0777). Anything unparseable falls back to a sane 0644.
123
+ */
124
+ function sanitizeIngestFileMode(mode) {
125
+ const DEFAULT_MODE = 0o644;
126
+ if (typeof mode !== 'string' || mode.trim() === '') {
127
+ return DEFAULT_MODE;
128
+ }
129
+ // Manifests send octal strings (e.g. "0644", "755"); parse base 8.
130
+ const parsed = parseInt(mode.trim(), 8);
131
+ if (Number.isNaN(parsed) || parsed < 0) {
132
+ return DEFAULT_MODE;
133
+ }
134
+ // Mask off setuid/setgid/sticky and any bits above the 0777 permission range.
135
+ return parsed & 0o777;
136
+ }
137
+ /**
138
+ * PRD #647 C5 (CodeRabbit) — strict canonical-base64 matcher. `Buffer.from(s,
139
+ * 'base64')` silently DROPS any out-of-alphabet character and tolerates missing
140
+ * padding, so a malformed `content` would otherwise decode to corrupt bytes and
141
+ * be cached. The CLI always uploads canonical, padded standard base64
142
+ * (Buffer#toString('base64')), so we require exactly that: only the standard
143
+ * alphabet (A–Z a–z 0–9 + /), `=` padding allowed only at the end, and a length
144
+ * that is a multiple of 4. The empty string (an empty file) is accepted.
145
+ */
146
+ const CANONICAL_BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
147
+ function isCanonicalBase64(content) {
148
+ return content.length % 4 === 0 && CANONICAL_BASE64_RE.test(content);
149
+ }
150
+ /**
151
+ * Root directory holding decoded ingested sources, a sibling of the git-clone
152
+ * cache directory so both live under the same writable tmp space.
153
+ */
154
+ function getIngestedCacheRoot() {
155
+ return path.join(path.dirname(getCacheDirectory()), 'ingested-prompts');
156
+ }
157
+ /**
158
+ * PRD #647 F6 — create a fresh, unpredictable 0700 staging directory under the
159
+ * ingested-cache root for an in-progress upload.
160
+ *
161
+ * Decoded files are written here FIRST and only promoted into the predictable
162
+ * per-identifier slot once every file is on disk (see F3 atomic re-ingest).
163
+ * Because the name is CSPRNG-random (crypto.randomUUID via mkdtempSync) and the
164
+ * directory is 0700, a local attacker can't pre-plant a symlink for the
165
+ * writeFileSync calls to follow (TOCTOU) — the same hardening the token-bearing
166
+ * clone path uses in createIsolatedCloneRoot.
167
+ */
168
+ function createIngestedStagingDir() {
169
+ const root = getIngestedCacheRoot();
170
+ fs.mkdirSync(root, { recursive: true });
171
+ const dir = fs.mkdtempSync(path.join(root, `staging-${crypto.randomUUID()}-`));
172
+ try {
173
+ fs.chmodSync(dir, 0o700);
174
+ }
175
+ catch {
176
+ /* best-effort hardening (mkdtempSync already creates with 0700) */
177
+ }
178
+ return dir;
179
+ }
180
+ /**
181
+ * PRD #647 F5 — enforce the LRU cap on the ingested-source registry. Evicts the
182
+ * least-recently-used entries (oldest `uploadedAt`) until the registry is within
183
+ * MAX_INGESTED_SOURCES, removing each evicted entry's on-disk directory too so
184
+ * memory AND disk stay bounded. An evicted identifier's next render falls into
185
+ * the D2 render-miss path (the registry entry is gone), instructing the caller
186
+ * to re-upload — it is never silently cloned.
187
+ */
188
+ function evictIngestedIfNeeded(logger) {
189
+ while (ingestedSources.size > exports.MAX_INGESTED_SOURCES) {
190
+ let oldestKey;
191
+ let oldestTime = Infinity;
192
+ for (const [key, entry] of ingestedSources) {
193
+ if (entry.uploadedAt < oldestTime) {
194
+ oldestTime = entry.uploadedAt;
195
+ oldestKey = key;
196
+ }
197
+ }
198
+ if (oldestKey === undefined)
199
+ break;
200
+ const evicted = ingestedSources.get(oldestKey);
201
+ ingestedSources.delete(oldestKey);
202
+ if (evicted) {
203
+ try {
204
+ fs.rmSync(evicted.localPath, { recursive: true, force: true });
205
+ }
206
+ catch {
207
+ /* best-effort disk cleanup; the registry entry is already gone */
208
+ }
209
+ logger.debug('Evicted ingested prompts source (LRU cap reached)', {
210
+ source: evicted.source,
211
+ });
212
+ }
213
+ }
214
+ }
215
+ /**
216
+ * PRD #647 M2/M4/D5 — validate, base64-decode, harden, and cache an uploaded
217
+ * skill source.
218
+ *
219
+ * The decoded files are written to a fresh 0700 staging directory, then
220
+ * atomically promoted into the per-identifier slot (a hash of the identifier)
221
+ * and registered in the in-memory ingested cache so the existing render path
222
+ * (loadUserPrompts → loadPromptsFromDir) resolves them with no git operation.
223
+ * The atomic promote (F3) means a failed/invalid re-upload never destroys the
224
+ * prior cached entry.
225
+ *
226
+ * Hardening, all applied BEFORE the prior slot is touched so a rejected upload
227
+ * is never partially cached:
228
+ * - D3 dedup: an identical `contentHash` already cached for this identifier
229
+ * short-circuits with status 'unchanged' — nothing is re-decoded or rewritten.
230
+ * - D5 file-count cap: more than MAX_INGEST_FILES files is rejected up front.
231
+ * - D5 total-size cap: summed DECODED bytes over MAX_INGEST_TOTAL_BYTES is rejected.
232
+ * - D5 zip-slip: every file path goes through sanitizeRelativePath (reused
233
+ * from git-utils) to reject traversal/absolute paths.
234
+ * - F3 NUL byte: a `\0` in a file path is rejected with a 400 BEFORE any fs
235
+ * call (sanitizeRelativePath does not catch it).
236
+ * - D5 mode bits: each file's POSIX `mode` is sanitized (setuid/setgid/sticky
237
+ * stripped) via sanitizeIngestFileMode before the file is written.
238
+ */
239
+ function ingestPromptsSource(input, logger) {
240
+ // Identifier (cache key) must be a non-empty string.
241
+ if (typeof input.source !== 'string' || input.source.trim() === '') {
242
+ throw new PromptsSourceValidationError('source is required and must be a non-empty string');
243
+ }
244
+ const identifier = input.source.trim();
245
+ const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
246
+ // PRD #647 D3 — content-hash dedup. If the caller sent a contentHash that is
247
+ // already cached for this identifier, the upload is byte-for-byte unchanged:
248
+ // short-circuit WITHOUT re-decoding or rewriting any files and report the
249
+ // cached file count. A different or absent hash falls through to a normal
250
+ // (re)ingest below.
251
+ if (contentHash) {
252
+ const cached = ingestedSources.get(identifier);
253
+ if (cached && cached.contentHash === contentHash) {
254
+ logger.info('Ingested prompts source unchanged (dedup short-circuit)', {
255
+ source: cached.source,
256
+ fileCount: cached.fileCount,
257
+ });
258
+ return {
259
+ source: cached.source,
260
+ contentHash,
261
+ fileCount: cached.fileCount,
262
+ status: 'unchanged',
263
+ };
264
+ }
265
+ }
266
+ // Manifest must carry at least one file.
267
+ if (!Array.isArray(input.files) || input.files.length === 0) {
268
+ throw new PromptsSourceValidationError('files is required and must be a non-empty array');
269
+ }
270
+ // PRD #647 D5 — file-count cap, enforced before any decode/write.
271
+ if (input.files.length > MAX_INGEST_FILES) {
272
+ throw new PromptsSourceValidationError(`Too many files: ${input.files.length} exceeds the limit of ${MAX_INGEST_FILES}`);
273
+ }
274
+ // Decode + path-validate EVERY file (and tally decoded bytes) before writing.
275
+ const decoded = [];
276
+ let totalBytes = 0;
277
+ for (const raw of input.files) {
278
+ if (!raw || typeof raw !== 'object') {
279
+ throw new PromptsSourceValidationError('each file must be an object with a path and base64 content');
280
+ }
281
+ const file = raw;
282
+ if (typeof file.path !== 'string' || file.path.trim() === '') {
283
+ throw new PromptsSourceValidationError('each file must have a non-empty string path');
284
+ }
285
+ if (typeof file.content !== 'string') {
286
+ throw new PromptsSourceValidationError(`file content must be a base64-encoded string: ${file.path}`);
287
+ }
288
+ // PRD #647 F3 — reject a NUL byte BEFORE any fs call. git-utils'
289
+ // sanitizeRelativePath does not catch `\0`, so without this an embedded NUL
290
+ // would slip through to fs.mkdirSync/writeFileSync and throw a raw
291
+ // TypeError → a generic 500 plus a partial write. Map it to a clean 400
292
+ // here, up front, so a rejected upload never touches disk. (Backslashes are
293
+ // left as ordinary POSIX path characters — they cannot escape the root —
294
+ // keeping parity with the mock's sanitizeRelativePath.)
295
+ if (file.path.includes('\0')) {
296
+ throw new PromptsSourceValidationError(`Invalid file path "${file.path}": contains null byte`);
297
+ }
298
+ let relPath;
299
+ try {
300
+ // Reuse the folder-write traversal/zip-slip guard.
301
+ relPath = (0, git_utils_1.sanitizeRelativePath)(file.path);
302
+ }
303
+ catch (error) {
304
+ const message = error instanceof Error ? error.message : 'invalid path';
305
+ throw new PromptsSourceValidationError(`Invalid file path "${file.path}": ${message}`);
306
+ }
307
+ // PRD #647 C5 — reject malformed base64 with a 400 BEFORE decoding, so a
308
+ // corrupt upload is never silently decoded (Buffer.from drops bad chars)
309
+ // and cached. Checked after the path guards so a rejected upload never
310
+ // touches disk.
311
+ if (!isCanonicalBase64(file.content)) {
312
+ throw new PromptsSourceValidationError(`Invalid base64 content for file "${file.path}"`);
313
+ }
314
+ const bytes = Buffer.from(file.content, 'base64');
315
+ totalBytes += bytes.length;
316
+ // PRD #647 D5 — total decoded payload cap, checked before any write so an
317
+ // oversized manifest is never partially cached.
318
+ if (totalBytes > MAX_INGEST_TOTAL_BYTES) {
319
+ throw new PromptsSourceValidationError(`Total decoded payload exceeds the limit of ${MAX_INGEST_TOTAL_BYTES} bytes`);
320
+ }
321
+ decoded.push({
322
+ relPath,
323
+ bytes,
324
+ // PRD #647 D5 — sanitize the untrusted mode (strip setuid/setgid/sticky).
325
+ mode: sanitizeIngestFileMode(file.mode),
326
+ });
327
+ }
328
+ // PRD #647 F3 — atomic re-ingest. Each identifier maps to a predictable
329
+ // per-identifier slot keyed by its hash, but we never write INTO that slot
330
+ // directly: write the decoded files into a fresh, unpredictable 0700 staging
331
+ // directory FIRST, then promote it into place only after every file is on
332
+ // disk. This guarantees a write-time failure (the NUL byte rejected above,
333
+ // EISDIR, disk full, …) NEVER destroys the previously-cached entry — the old
334
+ // slot is removed only on the success path, and the in-memory registry is
335
+ // updated last.
336
+ const finalDir = path.join(getIngestedCacheRoot(), crypto.createHash('sha256').update(identifier).digest('hex'));
337
+ const stagingDir = createIngestedStagingDir();
338
+ // PRD #647 N3 — count DISTINCT written paths: two manifest entries that
339
+ // sanitize to the same path collapse to one file on disk, so fileCount must
340
+ // not double-count them.
341
+ const writtenPaths = new Set();
342
+ // PRD #647 F3 (CodeRabbit C1) — failure-ATOMIC promote. The earlier version
343
+ // `rmSync(finalDir)`'d the prior slot BEFORE the rename, so a rename failure
344
+ // destroyed the last known-good entry (the map still pointed at finalDir).
345
+ // Instead: move the old slot aside to an unpredictable backup, rename staging
346
+ // into place, then delete the backup. If the promote throws, the backup is
347
+ // moved back so the prior cached entry is always recoverable. rename does not
348
+ // follow a symlink at the source or target, so a pre-planted finalDir symlink
349
+ // is moved/replaced, not written through.
350
+ const backupDir = `${finalDir}.bak-${crypto.randomUUID()}`;
351
+ let hadPrevious = false;
352
+ try {
353
+ for (const { relPath, bytes, mode } of decoded) {
354
+ const fullPath = path.join(stagingDir, relPath);
355
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
356
+ fs.writeFileSync(fullPath, bytes, { mode });
357
+ writtenPaths.add(relPath);
358
+ }
359
+ // Move any prior slot aside FIRST (so it can be restored on failure), then
360
+ // promote the fully-written staging dir into place.
361
+ if (fs.existsSync(finalDir)) {
362
+ fs.renameSync(finalDir, backupDir);
363
+ hadPrevious = true;
364
+ }
365
+ fs.renameSync(stagingDir, finalDir);
366
+ }
367
+ catch (error) {
368
+ // Roll back the partial staging dir; never leave it behind.
369
+ try {
370
+ fs.rmSync(stagingDir, { recursive: true, force: true });
371
+ }
372
+ catch {
373
+ /* best-effort cleanup of the abandoned staging dir */
374
+ }
375
+ // Restore the prior cached entry if it was moved aside but the promote
376
+ // never completed, so a failed re-upload truly never destroys it.
377
+ if (hadPrevious && fs.existsSync(backupDir) && !fs.existsSync(finalDir)) {
378
+ try {
379
+ fs.renameSync(backupDir, finalDir);
380
+ }
381
+ catch {
382
+ /* best-effort restore; backup remains on disk for manual recovery */
383
+ }
384
+ }
385
+ throw error;
386
+ }
387
+ // Promote succeeded — discard the prior good copy. Best-effort: a cleanup
388
+ // failure here must NOT fail an already-successful re-ingest (the new content
389
+ // is in place), at worst leaving a stale backup dir for the tmp sweep.
390
+ if (hadPrevious) {
391
+ try {
392
+ fs.rmSync(backupDir, { recursive: true, force: true });
393
+ }
394
+ catch {
395
+ /* best-effort cleanup of the superseded prior copy */
396
+ }
397
+ }
398
+ const fileCount = writtenPaths.size;
399
+ const scrubbedSource = scrubSourceUrl(identifier);
400
+ // PRD #647 F5 — re-insert so a re-upload counts as most-recently-used in the
401
+ // access-ordered LRU, then evict if the registry now exceeds the cap.
402
+ ingestedSources.delete(identifier);
403
+ ingestedSources.set(identifier, {
404
+ identifier,
405
+ source: scrubbedSource,
406
+ contentHash,
407
+ localPath: finalDir,
408
+ fileCount,
409
+ uploadedAt: Date.now(),
410
+ });
411
+ evictIngestedIfNeeded(logger);
412
+ logger.info('Ingested prompts source', {
413
+ source: scrubbedSource,
414
+ fileCount,
415
+ });
416
+ return {
417
+ source: scrubbedSource,
418
+ contentHash,
419
+ fileCount,
420
+ status: 'ingested',
421
+ };
422
+ }
423
+ /**
424
+ * Load prompts from a previously-ingested source (PRD #647 M3 + D2).
425
+ *
426
+ * Resolves the identifier from the in-memory ingested cache and reads the
427
+ * decoded upload directly — NO git operation. A miss (evicted/never-uploaded)
428
+ * throws IngestedSourceNotFoundError with re-upload guidance rather than
429
+ * silently falling back to a clone (D2: ingested identifiers are never cloned).
430
+ * The thrown message deliberately avoids the generic "Prompt not found" wording
431
+ * AND any git/clone/scheme vocabulary so the caller learns to re-upload and the
432
+ * "no clone attempted" guarantee is observable.
433
+ *
434
+ * Note the distinction the contract requires: a MISSING identifier (no cache
435
+ * entry) yields this guidance, whereas a CACHED identifier that simply does not
436
+ * contain the requested skill name returns the loaded prompts and lets the
437
+ * caller surface the normal "Prompt not found".
438
+ */
439
+ function loadIngestedPrompts(identifier, logger) {
440
+ const entry = ingestedSources.get(identifier);
441
+ if (!entry || !fs.existsSync(entry.localPath)) {
442
+ const scrubbed = scrubSourceUrl(identifier);
443
+ logger.warn('Ingested prompts source not found; (re)upload required', {
444
+ source: scrubbed,
445
+ });
446
+ throw new IngestedSourceNotFoundError(`Ingested source not found: ${scrubbed}. (Re)upload it via POST /api/v1/prompts/sources before rendering.`);
447
+ }
448
+ // PRD #647 F5 — mark this entry most-recently-used so a frequently-rendered
449
+ // source survives the LRU eviction in evictIngestedIfNeeded.
450
+ entry.uploadedAt = Date.now();
451
+ const prompts = loadPromptsFromDir(entry.localPath, logger);
452
+ logger.info('Loaded user prompts from ingested source', {
453
+ total: prompts.length,
454
+ source: entry.source,
455
+ });
456
+ return prompts;
457
+ }
66
458
  /**
67
459
  * Read user prompts configuration from environment variables
68
460
  * Returns null if DOT_AI_USER_PROMPTS_REPO is not set
@@ -525,14 +917,112 @@ function loadSkillFolder(dirPath, dirName, logger) {
525
917
  }
526
918
  return prompt;
527
919
  }
920
+ /**
921
+ * Read flat `.md` prompt files and skill folders (directories with SKILL.md)
922
+ * from a prompts directory into Prompt objects.
923
+ *
924
+ * Shared by the git-clone loader path and the PRD #647 ingested (uploaded)
925
+ * source path so both resolve through ONE identical loader — the only
926
+ * difference between a `?repo=` clone and an uploaded `?source=` is how the
927
+ * directory was populated. The caller is responsible for ensuring the directory
928
+ * exists.
929
+ */
930
+ function loadPromptsFromDir(promptsDir, logger) {
931
+ const entries = fs.readdirSync(promptsDir, { withFileTypes: true });
932
+ const prompts = [];
933
+ const loadedNames = new Set();
934
+ // 1. Load flat .md files (existing behavior)
935
+ const mdFiles = entries.filter(e => e.isFile() && e.name.endsWith('.md'));
936
+ for (const entry of mdFiles) {
937
+ try {
938
+ const filePath = path.join(promptsDir, entry.name);
939
+ const prompt = (0, prompts_1.loadPromptFile)(filePath, 'user');
940
+ prompts.push(prompt);
941
+ loadedNames.add(prompt.name);
942
+ logger.debug('Loaded user prompt', {
943
+ name: prompt.name,
944
+ file: entry.name,
945
+ });
946
+ }
947
+ catch (error) {
948
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
949
+ logger.warn('Failed to load user prompt file, skipping', {
950
+ file: entry.name,
951
+ error: errorMessage,
952
+ });
953
+ }
954
+ }
955
+ // 2. Load skill folders (directories containing SKILL.md)
956
+ const directories = entries.filter(e => e.isDirectory() && !e.name.startsWith('.'));
957
+ for (const dir of directories) {
958
+ try {
959
+ const dirPath = path.join(promptsDir, dir.name);
960
+ const prompt = loadSkillFolder(dirPath, dir.name, logger);
961
+ if (prompt) {
962
+ if (loadedNames.has(prompt.name)) {
963
+ logger.warn('Skill folder name collision with existing prompt, skipping', {
964
+ name: prompt.name,
965
+ dir: dir.name,
966
+ });
967
+ continue;
968
+ }
969
+ prompts.push(prompt);
970
+ loadedNames.add(prompt.name);
971
+ logger.debug('Loaded user skill folder', {
972
+ name: prompt.name,
973
+ dir: dir.name,
974
+ filesCount: prompt.files?.length ?? 0,
975
+ });
976
+ }
977
+ }
978
+ catch (error) {
979
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
980
+ logger.warn('Failed to load skill folder, skipping', {
981
+ dir: dir.name,
982
+ error: errorMessage,
983
+ });
984
+ }
985
+ }
986
+ return prompts;
987
+ }
988
+ /**
989
+ * Raised when a PER-REQUEST prompts-repo override (PRD #581/#621) fails to load —
990
+ * e.g. the clone is rejected (missing/wrong forwarded credential) or the source is
991
+ * unreachable. An env-var-configured repo failure falls back to built-in prompts,
992
+ * but an override is an explicit caller request, so its failure must surface as an
993
+ * error instead of silently returning fewer skills ("fail open" — issue #575).
994
+ *
995
+ * The `message` is already credential-scrubbed by the thrower, so callers may
996
+ * surface it to clients directly.
997
+ */
998
+ class UserPromptsOverrideError extends Error {
999
+ constructor(message) {
1000
+ super(message);
1001
+ this.name = 'UserPromptsOverrideError';
1002
+ }
1003
+ }
1004
+ exports.UserPromptsOverrideError = UserPromptsOverrideError;
528
1005
  /**
529
1006
  * Load user prompts from a git repository.
530
1007
  *
531
1008
  * When `override` is supplied, fetches from that repository for this call only,
532
1009
  * ignoring DOT_AI_USER_PROMPTS_REPO env vars. Otherwise, uses env-var configuration.
533
- * Returns empty array if not configured or on error.
1010
+ *
1011
+ * Returns an empty array when not configured, or when an ENV-VAR-configured repo
1012
+ * fails to load (graceful fallback to built-in prompts). When a per-request
1013
+ * `override` fails to load, throws {@link UserPromptsOverrideError} instead — the
1014
+ * caller asked for that specific source, so the failure must not be swallowed.
534
1015
  */
535
1016
  async function loadUserPrompts(logger, forceRefresh = false, override) {
1017
+ // PRD #647 D1/M3: an ingested-source override resolves from the in-memory
1018
+ // uploaded-source cache and is NEVER cloned. Short-circuit BEFORE
1019
+ // getUserPromptsConfigFromOverride, which would reject a non-http identifier
1020
+ // such as `local:<label>`. The render handler only sets this when the request
1021
+ // carries an explicit `?source=` signal, so the env-var and `?repo=` clone
1022
+ // paths below are untouched.
1023
+ if (override?.ingestedSource) {
1024
+ return loadIngestedPrompts(override.ingestedSource, logger);
1025
+ }
536
1026
  let config;
537
1027
  try {
538
1028
  // Override validation can throw on bad scheme / traversal / branch — keep
@@ -545,6 +1035,12 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
545
1035
  catch (error) {
546
1036
  const rawMessage = error instanceof Error ? error.message : 'Unknown error';
547
1037
  const safeMessage = (0, git_utils_1.scrubCredentials)(rawMessage);
1038
+ // A per-request override that fails validation is an explicit caller request
1039
+ // gone wrong — surface it (issue #575). Env-var config failures fall back.
1040
+ if (override) {
1041
+ logger.error('Per-request prompts override is invalid', new Error(safeMessage));
1042
+ throw new UserPromptsOverrideError(safeMessage);
1043
+ }
548
1044
  logger.error('Failed to load user prompts, falling back to built-in only', new Error(safeMessage));
549
1045
  return [];
550
1046
  }
@@ -569,62 +1065,8 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
569
1065
  });
570
1066
  return [];
571
1067
  }
572
- // Load flat .md files and skill folders from the prompts directory
573
- const entries = fs.readdirSync(promptsDir, { withFileTypes: true });
574
- const prompts = [];
575
- const loadedNames = new Set();
576
- // 1. Load flat .md files (existing behavior)
577
- const mdFiles = entries.filter(e => e.isFile() && e.name.endsWith('.md'));
578
- for (const entry of mdFiles) {
579
- try {
580
- const filePath = path.join(promptsDir, entry.name);
581
- const prompt = (0, prompts_1.loadPromptFile)(filePath, 'user');
582
- prompts.push(prompt);
583
- loadedNames.add(prompt.name);
584
- logger.debug('Loaded user prompt', {
585
- name: prompt.name,
586
- file: entry.name,
587
- });
588
- }
589
- catch (error) {
590
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
591
- logger.warn('Failed to load user prompt file, skipping', {
592
- file: entry.name,
593
- error: errorMessage,
594
- });
595
- }
596
- }
597
- // 2. Load skill folders (directories containing SKILL.md)
598
- const directories = entries.filter(e => e.isDirectory() && !e.name.startsWith('.'));
599
- for (const dir of directories) {
600
- try {
601
- const dirPath = path.join(promptsDir, dir.name);
602
- const prompt = loadSkillFolder(dirPath, dir.name, logger);
603
- if (prompt) {
604
- if (loadedNames.has(prompt.name)) {
605
- logger.warn('Skill folder name collision with existing prompt, skipping', {
606
- name: prompt.name,
607
- dir: dir.name,
608
- });
609
- continue;
610
- }
611
- prompts.push(prompt);
612
- loadedNames.add(prompt.name);
613
- logger.debug('Loaded user skill folder', {
614
- name: prompt.name,
615
- dir: dir.name,
616
- filesCount: prompt.files?.length ?? 0,
617
- });
618
- }
619
- }
620
- catch (error) {
621
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
622
- logger.warn('Failed to load skill folder, skipping', {
623
- dir: dir.name,
624
- error: errorMessage,
625
- });
626
- }
627
- }
1068
+ // Load flat .md files and skill folders from the prompts directory.
1069
+ const prompts = loadPromptsFromDir(promptsDir, logger);
628
1070
  logger.info('Loaded user prompts from repository', {
629
1071
  total: prompts.length,
630
1072
  url: sanitizeUrlForLogging(config.repoUrl),
@@ -638,6 +1080,14 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
638
1080
  const safeMessage = (0, git_utils_1.scrubCredentials)(config.gitToken
639
1081
  ? rawMessage.replaceAll(config.gitToken, '***')
640
1082
  : rawMessage);
1083
+ // A per-request override clone failure (bad/missing forwarded credential,
1084
+ // unreachable host, missing branch/subdir) must NOT silently fall back to
1085
+ // built-in prompts — the caller explicitly requested this source, so surface
1086
+ // the failure (issue #575). Env-var-configured repo failures still fall back.
1087
+ if (override) {
1088
+ logger.error('Failed to load per-request prompts override', new Error(safeMessage));
1089
+ throw new UserPromptsOverrideError(safeMessage);
1090
+ }
641
1091
  logger.error('Failed to load user prompts, falling back to built-in only', new Error(safeMessage));
642
1092
  return [];
643
1093
  }
@@ -667,6 +1117,21 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
667
1117
  function clearUserPromptsCache() {
668
1118
  cacheState = null;
669
1119
  }
1120
+ /**
1121
+ * Clear the ingested-source cache (PRD #647, useful for testing).
1122
+ * Only drops the in-memory registry; on-disk directories are left to be
1123
+ * overwritten by the next upload or cleaned with the tmp directory.
1124
+ */
1125
+ function clearIngestedPromptsSources() {
1126
+ ingestedSources.clear();
1127
+ }
1128
+ /**
1129
+ * Inspect the ingested-source cache (PRD #647, for testing/debugging).
1130
+ * Returns the scrubbed identifiers currently cached.
1131
+ */
1132
+ function getIngestedPromptsSources() {
1133
+ return [...ingestedSources.values()].map(entry => entry.source);
1134
+ }
670
1135
  /**
671
1136
  * Get current cache state (for testing/debugging)
672
1137
  */
@@ -73,7 +73,29 @@ export declare class MCPServer {
73
73
  private generateRequestId;
74
74
  start(): Promise<void>;
75
75
  private startHttpTransport;
76
+ /**
77
+ * Buffer and JSON-parse the request body.
78
+ *
79
+ * PRD #647 F1: when `maxBytes` is supplied (the untrusted ingest route), the
80
+ * body is rejected with RequestBodyTooLargeError as soon as the declared
81
+ * Content-Length OR the accumulated bytes exceed the ceiling — so an
82
+ * authenticated multi-GB POST can't buffer unbounded and OOM the shared
83
+ * process. When `maxBytes` is omitted (every other endpoint), behavior is
84
+ * exactly as before: no cap.
85
+ */
76
86
  private parseRequestBody;
87
+ /**
88
+ * PRD #647 F1: true only for the prompts source ingest endpoint, the one
89
+ * untrusted route the raw-body cap is scoped to. Parses the pathname so a
90
+ * query string can't bypass (or wrongly trip) the cap.
91
+ *
92
+ * PRD #647 C2 (CodeRabbit): the cap check runs BEFORE route dispatch, so a
93
+ * non-canonical pathname must be normalized here or it slips past the cap and
94
+ * buffers an uncapped body (DoS). A strict `===` let `POST /api/v1/prompts/
95
+ * sources/` (trailing slash) — the same ingest surface — skip `maxBytes`.
96
+ * Collapse trailing slashes (keeping a bare "/" intact) before comparing.
97
+ */
98
+ private isPromptsIngestRequest;
77
99
  stop(): Promise<void>;
78
100
  isReady(): boolean;
79
101
  }
@@ -1 +1 @@
1
- {"version":3,"file":"mcp.d.ts","sourceRoot":"","sources":["../../src/interfaces/mcp.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAYH,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAgFtC,OAAO,EAAgB,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAcvD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,aAAa,CAAC,EAAE,aAAa,CAAC;CAC/B;AAmBD,qBAAa,SAAS;IACpB,OAAO,CAAC,KAAK,CAAQ;IACrB,OAAO,CAAC,WAAW,CAAkB;IACrC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,gBAAgB,CAAa;IACrC,OAAO,CAAC,MAAM,CAAkB;IAChC,OAAO,CAAC,UAAU,CAAC,CAAkC;IACrD,4EAA4E;IAC5E,OAAO,CAAC,QAAQ,CAAiC;IACjD,OAAO,CAAC,cAAc,CAAC,CAAiC;IACxD,OAAO,CAAC,YAAY,CAAmB;IACvC,OAAO,CAAC,aAAa,CAAgB;IACrC,OAAO,CAAC,aAAa,CAAC,CAAgB;IACtC,OAAO,CAAC,QAAQ,CAAC,CAA6B;IAC9C,OAAO,CAAC,aAAa,CAAC,CAAqB;IAC3C,OAAO,CAAC,SAAS,CAAC,CAAM;gBAEZ,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe;IA6BjD;;;OAGG;IACH,gBAAgB,IAAI,aAAa,GAAG,SAAS;IAQ7C;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAuBxB;;OAEG;IACH,OAAO,CAAC,eAAe;IA2CvB;;OAEG;IACH,OAAO,CAAC,WAAW;IA8KnB;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAmBzB;;;;OAIG;YACW,mBAAmB;IA6CjC;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAkCzB,OAAO,CAAC,qBAAqB;IAS7B;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAWzB,OAAO,CAAC,iBAAiB;IAInB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAed,kBAAkB;YAgSlB,gBAAgB;IAexB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAoC3B,OAAO,IAAI,OAAO;CAGnB"}
1
+ {"version":3,"file":"mcp.d.ts","sourceRoot":"","sources":["../../src/interfaces/mcp.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAYH,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAgFtC,OAAO,EAAgB,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAcvD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,aAAa,CAAC,EAAE,aAAa,CAAC;CAC/B;AAqDD,qBAAa,SAAS;IACpB,OAAO,CAAC,KAAK,CAAQ;IACrB,OAAO,CAAC,WAAW,CAAkB;IACrC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,gBAAgB,CAAa;IACrC,OAAO,CAAC,MAAM,CAAkB;IAChC,OAAO,CAAC,UAAU,CAAC,CAAkC;IACrD,4EAA4E;IAC5E,OAAO,CAAC,QAAQ,CAAiC;IACjD,OAAO,CAAC,cAAc,CAAC,CAAiC;IACxD,OAAO,CAAC,YAAY,CAAmB;IACvC,OAAO,CAAC,aAAa,CAAgB;IACrC,OAAO,CAAC,aAAa,CAAC,CAAgB;IACtC,OAAO,CAAC,QAAQ,CAAC,CAA6B;IAC9C,OAAO,CAAC,aAAa,CAAC,CAAqB;IAC3C,OAAO,CAAC,SAAS,CAAC,CAAM;gBAEZ,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe;IA6BjD;;;OAGG;IACH,gBAAgB,IAAI,aAAa,GAAG,SAAS;IAQ7C;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAuBxB;;OAEG;IACH,OAAO,CAAC,eAAe;IA2CvB;;OAEG;IACH,OAAO,CAAC,WAAW;IA8KnB;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAmBzB;;;;OAIG;YACW,mBAAmB;IA6CjC;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAkCzB,OAAO,CAAC,qBAAqB;IAS7B;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAWzB,OAAO,CAAC,iBAAiB;IAInB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAed,kBAAkB;IA0ThC;;;;;;;;;OASG;YACW,gBAAgB;IAgD9B;;;;;;;;;;OAUG;IACH,OAAO,CAAC,sBAAsB;IAWxB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAoC3B,OAAO,IAAI,OAAO;CAGnB"}