@vfarcic/dot-ai 1.21.1 → 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.
Files changed (34) hide show
  1. package/dist/core/git-utils.d.ts +46 -0
  2. package/dist/core/git-utils.d.ts.map +1 -1
  3. package/dist/core/git-utils.js +192 -1
  4. package/dist/core/user-prompts-loader.d.ts +129 -1
  5. package/dist/core/user-prompts-loader.d.ts.map +1 -1
  6. package/dist/core/user-prompts-loader.js +654 -68
  7. package/dist/interfaces/cors-headers.d.ts +41 -0
  8. package/dist/interfaces/cors-headers.d.ts.map +1 -0
  9. package/dist/interfaces/cors-headers.js +43 -0
  10. package/dist/interfaces/header-redaction.d.ts +28 -0
  11. package/dist/interfaces/header-redaction.d.ts.map +1 -0
  12. package/dist/interfaces/header-redaction.js +48 -0
  13. package/dist/interfaces/mcp.d.ts +22 -0
  14. package/dist/interfaces/mcp.d.ts.map +1 -1
  15. package/dist/interfaces/mcp.js +131 -7
  16. package/dist/interfaces/openapi-generator.d.ts.map +1 -1
  17. package/dist/interfaces/openapi-generator.js +11 -5
  18. package/dist/interfaces/rest-api.d.ts +59 -15
  19. package/dist/interfaces/rest-api.d.ts.map +1 -1
  20. package/dist/interfaces/rest-api.js +288 -52
  21. package/dist/interfaces/routes/index.d.ts.map +1 -1
  22. package/dist/interfaces/routes/index.js +35 -0
  23. package/dist/interfaces/schemas/common.d.ts +33 -0
  24. package/dist/interfaces/schemas/common.d.ts.map +1 -1
  25. package/dist/interfaces/schemas/common.js +20 -1
  26. package/dist/interfaces/schemas/index.d.ts +2 -2
  27. package/dist/interfaces/schemas/index.d.ts.map +1 -1
  28. package/dist/interfaces/schemas/index.js +14 -3
  29. package/dist/interfaces/schemas/prompts.d.ts +155 -0
  30. package/dist/interfaces/schemas/prompts.d.ts.map +1 -1
  31. package/dist/interfaces/schemas/prompts.js +134 -1
  32. package/dist/tools/prompts.d.ts.map +1 -1
  33. package/dist/tools/prompts.js +37 -2
  34. package/package.json +6 -4
@@ -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,14 +57,404 @@ 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"));
60
65
  const os = __importStar(require("os"));
66
+ const crypto = __importStar(require("crypto"));
61
67
  const git_utils_1 = require("./git-utils");
62
68
  const prompts_1 = require("../tools/prompts");
63
69
  // In-memory cache state (persists across requests within same process)
64
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
+ }
65
458
  /**
66
459
  * Read user prompts configuration from environment variables
67
460
  * Returns null if DOT_AI_USER_PROMPTS_REPO is not set
@@ -160,7 +553,10 @@ function getUserPromptsConfigFromOverride(override) {
160
553
  repoUrl: override.repoUrl,
161
554
  branch,
162
555
  subPath: normalizedSubPath,
163
- gitToken: process.env.DOT_AI_GIT_TOKEN,
556
+ // PRD #621 M2 / Decision 4: a request-supplied token (override.gitToken)
557
+ // takes precedence over the server env credential for this request only;
558
+ // the env credential remains the fallback when no header is present.
559
+ gitToken: override.gitToken ?? process.env.DOT_AI_GIT_TOKEN,
164
560
  cacheTtlSeconds,
165
561
  };
166
562
  }
@@ -254,9 +650,36 @@ function isValidGitBranch(branch) {
254
650
  return /^[a-zA-Z0-9_.\-/]+$/.test(branch);
255
651
  }
256
652
  /**
257
- * Clone the user prompts repository
653
+ * Create a unique, throwaway ROOT directory for a token-bearing override
654
+ * request (PRD #621 M3 / Decision 2). It lives alongside the shared cache
655
+ * directory but is NOT the shared slot, so an authenticated private clone is
656
+ * never written to (or served from) the unauthenticated cache. The caller
657
+ * removes it after reading.
658
+ *
659
+ * Hardening (LOW-4): created atomically via fs.mkdtempSync with a CSPRNG
660
+ * (crypto.randomUUID) name component and mode 0700, so the authenticated clone
661
+ * cannot land in a predictable, world-readable location.
662
+ */
663
+ function createIsolatedCloneRoot() {
664
+ const parent = path.dirname(getCacheDirectory());
665
+ const root = fs.mkdtempSync(path.join(parent, `user-prompts-override-${crypto.randomUUID()}-`));
666
+ try {
667
+ fs.chmodSync(root, 0o700);
668
+ }
669
+ catch {
670
+ /* best-effort hardening (mkdtempSync already creates with 0700) */
671
+ }
672
+ return root;
673
+ }
674
+ /**
675
+ * Clone the user prompts repository.
676
+ *
677
+ * `overrideToken` (PRD #621 M3) is a per-request credential that, when present,
678
+ * overrides env auth for this clone only and is scoped to the source host with
679
+ * no cross-host redirect forwarding (handled in cloneRepo). When omitted, the
680
+ * clone authenticates via env exactly as before.
258
681
  */
259
- async function cloneRepository(config, localPath, logger) {
682
+ async function cloneRepository(config, localPath, logger, overrideToken) {
260
683
  // Validate branch name as defense-in-depth
261
684
  if (!isValidGitBranch(config.branch)) {
262
685
  throw new Error(`Invalid branch name: ${config.branch}`);
@@ -280,6 +703,9 @@ async function cloneRepository(config, localPath, logger) {
280
703
  await (0, git_utils_1.cloneRepo)(config.repoUrl, localPath, {
281
704
  branch: config.branch,
282
705
  depth: 1,
706
+ // Per-request override credential (PRD #621 M3). undefined → cloneRepo
707
+ // falls back to env auth, i.e. today's behavior unchanged.
708
+ token: overrideToken,
283
709
  });
284
710
  logger.info('Successfully cloned user prompts repository', {
285
711
  url: sanitizedUrl,
@@ -287,14 +713,24 @@ async function cloneRepository(config, localPath, logger) {
287
713
  });
288
714
  }
289
715
  catch (error) {
716
+ const scrub = (raw) => (0, git_utils_1.scrubCredentials)(config.gitToken ? raw.replaceAll(config.gitToken, '***') : raw);
290
717
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
291
- const sanitizedError = (0, git_utils_1.scrubCredentials)(config.gitToken
292
- ? errorMessage.replaceAll(config.gitToken, '***')
293
- : errorMessage);
718
+ const sanitizedError = scrub(errorMessage);
294
719
  logger.error('Failed to clone user prompts repository', new Error(sanitizedError), {
295
720
  url: sanitizedUrl,
296
721
  branch: config.branch,
297
722
  });
723
+ // LOW-5: scrub the caught error IN PLACE (message + stack) before attaching
724
+ // it as `cause`, so a serialized `.cause` cannot leak the token. (With the
725
+ // GIT_ASKPASS rework the override token is no longer on the git argv/URL, so
726
+ // it cannot appear here in the first place — this is defense-in-depth, esp.
727
+ // for the env-credential path which still embeds its token in the URL.)
728
+ if (error instanceof Error) {
729
+ error.message = scrub(error.message);
730
+ if (error.stack) {
731
+ error.stack = scrub(error.stack);
732
+ }
733
+ }
298
734
  throw new Error(`Failed to clone user prompts repository: ${sanitizedError}`, { cause: error });
299
735
  }
300
736
  }
@@ -326,10 +762,56 @@ async function pullRepository(config, localPath, logger) {
326
762
  }
327
763
  }
328
764
  /**
329
- * Ensure the repository is cloned and up-to-date
330
- * Returns the path to the prompts directory within the repository
765
+ * Ensure the repository is cloned and up-to-date.
766
+ *
767
+ * Returns the path to the prompts directory within the repository, plus an
768
+ * optional `isolatedRoot` the caller must remove after reading (set only for
769
+ * the token-bearing isolation path below).
770
+ *
771
+ * PRD #621 M3 / Decision 2 (cache isolation): when `overrideToken` is present
772
+ * (a request forwarded an X-Dot-AI-Git-Token), the clone is performed into a
773
+ * unique throwaway directory and the shared `cacheState` is neither read nor
774
+ * written. This guarantees an authenticated private clone is never served from
775
+ * — nor written into — the shared unauthenticated cache slot for the same
776
+ * (repoUrl, branch, subPath) coordinate, and that the token never enters the
777
+ * cache key. Token-less requests use the shared cache exactly as before.
331
778
  */
332
- async function ensureRepository(config, logger, forceRefresh = false) {
779
+ async function ensureRepository(config, logger, forceRefresh = false, overrideToken) {
780
+ if (overrideToken) {
781
+ const isolatedRoot = createIsolatedCloneRoot();
782
+ // Clone into a subdirectory of the 0700 root so the root's restrictive
783
+ // permissions cover the authenticated clone (git creates `cloneDir` itself).
784
+ const cloneDir = path.join(isolatedRoot, 'repo');
785
+ logger.debug('Token-bearing override: cloning in isolation', {
786
+ url: sanitizeUrlForLogging(config.repoUrl),
787
+ branch: config.branch,
788
+ });
789
+ try {
790
+ await cloneRepository(config, cloneDir, logger, overrideToken);
791
+ }
792
+ catch (error) {
793
+ // Remove any partial clone before propagating so a failed authenticated
794
+ // request leaves no isolated directory behind. With GIT_ASKPASS the token
795
+ // is never written to disk, so a cleanup failure cannot leave a PAT
796
+ // behind — but warn (don't swallow) for observability.
797
+ try {
798
+ fs.rmSync(isolatedRoot, { recursive: true, force: true });
799
+ }
800
+ catch (cleanupError) {
801
+ logger.warn('Failed to remove isolated clone directory after clone failure', {
802
+ path: isolatedRoot,
803
+ error: cleanupError instanceof Error
804
+ ? cleanupError.message
805
+ : String(cleanupError),
806
+ });
807
+ }
808
+ throw error;
809
+ }
810
+ const promptsDir = config.subPath
811
+ ? path.join(cloneDir, config.subPath)
812
+ : cloneDir;
813
+ return { promptsDir, isolatedRoot };
814
+ }
333
815
  const localPath = getCacheDirectory();
334
816
  const now = Date.now();
335
817
  const ttlMs = config.cacheTtlSeconds * 1000;
@@ -375,7 +857,11 @@ async function ensureRepository(config, logger, forceRefresh = false) {
375
857
  });
376
858
  }
377
859
  // Return path to prompts directory (with optional subPath)
378
- return config.subPath ? path.join(localPath, config.subPath) : localPath;
860
+ return {
861
+ promptsDir: config.subPath
862
+ ? path.join(localPath, config.subPath)
863
+ : localPath,
864
+ };
379
865
  }
380
866
  const SKILL_FILE_MAX_BYTES = 5 * 1024 * 1024; // 5 MB per file (before base64 encoding)
381
867
  const SKILL_FILENAME = 'SKILL.md';
@@ -431,14 +917,112 @@ function loadSkillFolder(dirPath, dirName, logger) {
431
917
  }
432
918
  return prompt;
433
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;
434
1005
  /**
435
1006
  * Load user prompts from a git repository.
436
1007
  *
437
1008
  * When `override` is supplied, fetches from that repository for this call only,
438
1009
  * ignoring DOT_AI_USER_PROMPTS_REPO env vars. Otherwise, uses env-var configuration.
439
- * 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.
440
1015
  */
441
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
+ }
442
1026
  let config;
443
1027
  try {
444
1028
  // Override validation can throw on bad scheme / traversal / branch — keep
@@ -451,6 +1035,12 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
451
1035
  catch (error) {
452
1036
  const rawMessage = error instanceof Error ? error.message : 'Unknown error';
453
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
+ }
454
1044
  logger.error('Failed to load user prompts, falling back to built-in only', new Error(safeMessage));
455
1045
  return [];
456
1046
  }
@@ -458,8 +1048,16 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
458
1048
  logger.debug('User prompts not configured (DOT_AI_USER_PROMPTS_REPO not set)');
459
1049
  return [];
460
1050
  }
1051
+ // PRD #621 M3 / Decision 2: a request-forwarded credential (override.gitToken)
1052
+ // triggers per-request cache isolation. Track the throwaway clone directory so
1053
+ // it is removed after the read (success-path cleanup; the failure path is
1054
+ // cleaned up inside ensureRepository).
1055
+ const overrideToken = override?.gitToken;
1056
+ let isolatedRoot;
461
1057
  try {
462
- const promptsDir = await ensureRepository(config, logger, forceRefresh);
1058
+ const ensured = await ensureRepository(config, logger, forceRefresh, overrideToken);
1059
+ const promptsDir = ensured.promptsDir;
1060
+ isolatedRoot = ensured.isolatedRoot;
463
1061
  if (!fs.existsSync(promptsDir)) {
464
1062
  logger.warn('User prompts directory not found in repository', {
465
1063
  path: promptsDir,
@@ -467,62 +1065,8 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
467
1065
  });
468
1066
  return [];
469
1067
  }
470
- // Load flat .md files and skill folders from the prompts directory
471
- const entries = fs.readdirSync(promptsDir, { withFileTypes: true });
472
- const prompts = [];
473
- const loadedNames = new Set();
474
- // 1. Load flat .md files (existing behavior)
475
- const mdFiles = entries.filter(e => e.isFile() && e.name.endsWith('.md'));
476
- for (const entry of mdFiles) {
477
- try {
478
- const filePath = path.join(promptsDir, entry.name);
479
- const prompt = (0, prompts_1.loadPromptFile)(filePath, 'user');
480
- prompts.push(prompt);
481
- loadedNames.add(prompt.name);
482
- logger.debug('Loaded user prompt', {
483
- name: prompt.name,
484
- file: entry.name,
485
- });
486
- }
487
- catch (error) {
488
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
489
- logger.warn('Failed to load user prompt file, skipping', {
490
- file: entry.name,
491
- error: errorMessage,
492
- });
493
- }
494
- }
495
- // 2. Load skill folders (directories containing SKILL.md)
496
- const directories = entries.filter(e => e.isDirectory() && !e.name.startsWith('.'));
497
- for (const dir of directories) {
498
- try {
499
- const dirPath = path.join(promptsDir, dir.name);
500
- const prompt = loadSkillFolder(dirPath, dir.name, logger);
501
- if (prompt) {
502
- if (loadedNames.has(prompt.name)) {
503
- logger.warn('Skill folder name collision with existing prompt, skipping', {
504
- name: prompt.name,
505
- dir: dir.name,
506
- });
507
- continue;
508
- }
509
- prompts.push(prompt);
510
- loadedNames.add(prompt.name);
511
- logger.debug('Loaded user skill folder', {
512
- name: prompt.name,
513
- dir: dir.name,
514
- filesCount: prompt.files?.length ?? 0,
515
- });
516
- }
517
- }
518
- catch (error) {
519
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
520
- logger.warn('Failed to load skill folder, skipping', {
521
- dir: dir.name,
522
- error: errorMessage,
523
- });
524
- }
525
- }
1068
+ // Load flat .md files and skill folders from the prompts directory.
1069
+ const prompts = loadPromptsFromDir(promptsDir, logger);
526
1070
  logger.info('Loaded user prompts from repository', {
527
1071
  total: prompts.length,
528
1072
  url: sanitizeUrlForLogging(config.repoUrl),
@@ -536,9 +1080,36 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
536
1080
  const safeMessage = (0, git_utils_1.scrubCredentials)(config.gitToken
537
1081
  ? rawMessage.replaceAll(config.gitToken, '***')
538
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
+ }
539
1091
  logger.error('Failed to load user prompts, falling back to built-in only', new Error(safeMessage));
540
1092
  return [];
541
1093
  }
1094
+ finally {
1095
+ // PRD #621 M3 / Decision 2: remove the per-request isolated clone (if any)
1096
+ // so token-bearing override clones leave no on-disk residue. With
1097
+ // GIT_ASKPASS the token is never written to disk, so a failed cleanup
1098
+ // cannot leave a PAT behind — but warn (don't swallow) for observability.
1099
+ if (isolatedRoot) {
1100
+ try {
1101
+ fs.rmSync(isolatedRoot, { recursive: true, force: true });
1102
+ }
1103
+ catch (cleanupError) {
1104
+ logger.warn('Failed to remove isolated clone directory', {
1105
+ path: isolatedRoot,
1106
+ error: cleanupError instanceof Error
1107
+ ? cleanupError.message
1108
+ : String(cleanupError),
1109
+ });
1110
+ }
1111
+ }
1112
+ }
542
1113
  }
543
1114
  /**
544
1115
  * Clear the cache state (useful for testing)
@@ -546,6 +1117,21 @@ async function loadUserPrompts(logger, forceRefresh = false, override) {
546
1117
  function clearUserPromptsCache() {
547
1118
  cacheState = null;
548
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
+ }
549
1135
  /**
550
1136
  * Get current cache state (for testing/debugging)
551
1137
  */