ofw-mcp 2.19.2 → 2.19.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js CHANGED
@@ -33,8 +33,24 @@ export function getAttachmentsDir() {
33
33
  // Claude Desktop, so files written there are unreadable to the model that
34
34
  // just downloaded them. Downloads is the standard "user-accessible files"
35
35
  // location across macOS/Linux/Windows.
36
+ return getDefaultAttachmentsDir();
37
+ }
38
+ /** The dedicated default attachments directory, `~/Downloads/ofw-mcp`. */
39
+ export function getDefaultAttachmentsDir() {
36
40
  return join(homedir(), 'Downloads', 'ofw-mcp');
37
41
  }
42
+ /**
43
+ * The only directory tree ofw_upload_attachment may read from. Uploading sends
44
+ * a local file to OurFamilyWizard (and, when shared, straight to the
45
+ * co-parent), so the source is confined to a directory the user deliberately
46
+ * put files in: OFW_UPLOAD_DIR, else the attachments directory.
47
+ */
48
+ export function getUploadDir() {
49
+ const override = process.env.OFW_UPLOAD_DIR;
50
+ if (override && override.trim().length > 0)
51
+ return override.trim();
52
+ return getAttachmentsDir();
53
+ }
38
54
  /**
39
55
  * Gate for write-tool registration, read at registration time (startup).
40
56
  *
@@ -12,6 +12,9 @@ import { readZip } from './zip.js';
12
12
  import { elements, attr, textOf } from './xml.js';
13
13
  import { readRels, findPart, dirOf, resolvePartPath } from './ooxml.js';
14
14
  const DEFAULT_MAX_CELLS = 200_000;
15
+ const DEFAULT_MAX_GRID_CELLS = 2_000_000;
16
+ /** Excel's hard sheet width: column XFD. A reference past it is malformed. */
17
+ const MAX_COLUMNS = 16_384;
15
18
  // Built-in number formats that denote a date and/or time (ECMA-376 §18.8.30).
16
19
  const BUILTIN_DATE_FORMATS = new Set([14, 15, 16, 17, 18, 19, 20, 21, 22, 45, 46, 47]);
17
20
  /** Excel's serial-date epoch as a Unix-epoch offset, in days. */
@@ -47,15 +50,20 @@ function isDateFormat(numFmtId, formatCode) {
47
50
  const bare = formatCode.replace(/"[^"]*"/g, '').replace(/\[[^\]]*\]/g, '').replace(/\\./g, '');
48
51
  return /[ymdhs]/i.test(bare);
49
52
  }
50
- /** Column index (0-based) from a cell reference like `AA12`. */
53
+ /**
54
+ * Column index (0-based) from a cell reference like `AA12`, or null when the
55
+ * reference has no column letters or points past Excel's last column (XFD).
56
+ * The bound matters: a crafted `DZZZZZZ1` would otherwise ask for ~1.4e9
57
+ * columns and the CSV build would die in an uncatchable heap OOM.
58
+ */
51
59
  function columnIndex(ref) {
52
- const m = /^([A-Z]+)/.exec(ref);
60
+ const m = /^([A-Z]{1,3})(?![A-Z])/.exec(ref);
53
61
  if (!m)
54
62
  return null;
55
63
  let index = 0;
56
64
  for (const ch of m[1])
57
65
  index = index * 26 + (ch.charCodeAt(0) - 64);
58
- return index - 1;
66
+ return index > MAX_COLUMNS ? null : index - 1;
59
67
  }
60
68
  /** Quote a CSV field per RFC 4180 when it contains a delimiter, quote or newline. */
61
69
  function csvField(value) {
@@ -121,7 +129,7 @@ function cellValue(attrs, inner, ctx) {
121
129
  }
122
130
  }
123
131
  }
124
- function parseSheet(xml, name, ctx, maxCells) {
132
+ function parseSheet(xml, name, ctx, maxCells, maxGridCells) {
125
133
  const rows = [];
126
134
  let cols = 0;
127
135
  let cells = 0;
@@ -132,6 +140,7 @@ function parseSheet(xml, name, ctx, maxCells) {
132
140
  break;
133
141
  }
134
142
  const values = [];
143
+ let rowCols = cols;
135
144
  for (const cell of elements(row.inner, 'c')) {
136
145
  const ref = attr(cell.attrs, 'r');
137
146
  const index = ref === null ? null : columnIndex(ref);
@@ -141,9 +150,15 @@ function parseSheet(xml, name, ctx, maxCells) {
141
150
  continue;
142
151
  values[index] = cellValue(cell.attrs, cell.inner, ctx);
143
152
  cells++;
144
- if (index + 1 > cols)
145
- cols = index + 1;
153
+ if (index + 1 > rowCols)
154
+ rowCols = index + 1;
155
+ }
156
+ // Stop before the padded grid the CSV is built from outgrows its budget.
157
+ if ((rows.length + 1) * rowCols > maxGridCells) {
158
+ truncated = true;
159
+ break;
146
160
  }
161
+ cols = rowCols;
147
162
  rows.push(values);
148
163
  }
149
164
  return { name, rows: rows.length, cols, csv: toCsv(rows, cols), ...(truncated ? { truncated } : {}) };
@@ -165,6 +180,7 @@ export async function extractXlsx(bytes, opts = {}) {
165
180
  date1904: /<workbookPr[^>]*date1904="(1|true)"/i.test(workbookXml),
166
181
  };
167
182
  const maxCells = opts.maxCells ?? DEFAULT_MAX_CELLS;
183
+ const maxGridCells = opts.maxGridCells ?? DEFAULT_MAX_GRID_CELLS;
168
184
  const sheets = [];
169
185
  const omitted = [];
170
186
  let index = 0;
@@ -185,7 +201,7 @@ export async function extractXlsx(bytes, opts = {}) {
185
201
  omitted.push(`${name} (sheet part not found in the workbook)`);
186
202
  continue;
187
203
  }
188
- sheets.push(parseSheet(xml, name, ctx, maxCells));
204
+ sheets.push(parseSheet(xml, name, ctx, maxCells, maxGridCells));
189
205
  }
190
206
  const truncated = sheets.some((s) => s.truncated);
191
207
  return {
@@ -248,11 +264,18 @@ function parseDelimitedRows(text, delimiter) {
248
264
  return rows;
249
265
  }
250
266
  /** Extract .csv/.tsv text as a single-sheet spreadsheet. */
251
- export function extractDelimited(text, name, delimiter) {
252
- const rows = parseDelimitedRows(text, delimiter);
267
+ export function extractDelimited(text, name, delimiter, maxGridCells = DEFAULT_MAX_GRID_CELLS) {
268
+ let rows = parseDelimitedRows(text, delimiter);
253
269
  const cols = rows.reduce((max, r) => Math.max(max, r.length), 0);
270
+ // Every row is padded to the widest one, so a single very wide line would
271
+ // otherwise multiply the output by the row count.
272
+ const keep = cols === 0 ? rows.length : Math.floor(maxGridCells / cols);
273
+ const truncated = rows.length > keep;
274
+ if (truncated)
275
+ rows = rows.slice(0, keep);
254
276
  return {
255
277
  kind: 'spreadsheet',
256
- sheets: [{ name, rows: rows.length, cols, csv: toCsv(rows, cols) }],
278
+ sheets: [{ name, rows: rows.length, cols, csv: toCsv(rows, cols), ...(truncated ? { truncated } : {}) }],
279
+ ...(truncated ? { truncated } : {}),
257
280
  };
258
281
  }
package/dist/index.js CHANGED
@@ -36,7 +36,7 @@ const nodeAttachmentIO = new NodeAttachmentIO();
36
36
  // always succeeds before any credential check runs.
37
37
  await runMcp({
38
38
  name: 'ofw',
39
- version: '2.19.2', // x-release-please-version
39
+ version: '2.19.4', // x-release-please-version
40
40
  deps: client,
41
41
  tools: [
42
42
  registerHealthcheckTools,
package/dist/sync.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { ApiRecipientSchema, hasRealView, mapRecipients, threadedReplyTo } from './tools/_shared.js';
3
3
  import { parseLenient } from '@chrischall/mcp-utils';
4
+ import { nowNaiveWallClock } from './timestamps.js';
4
5
  // Each OFW message detail returns `files: [fileId, ...]`. We fetch the metadata
5
6
  // for each file id (cheap JSON call) so the model can see filenames/mime types
6
7
  // without downloading bytes. Bytes are pulled lazily by ofw_download_attachment.
@@ -212,7 +213,7 @@ async function walkPages(client, folder, folderId, opts, store) {
212
213
  folder,
213
214
  subject: item.subject ?? '(no subject)',
214
215
  fromUser: item.from?.name ?? '',
215
- sentAt: item.date?.dateTime ?? new Date().toISOString(),
216
+ sentAt: item.date?.dateTime ?? nowNaiveWallClock(),
216
217
  recipients: mapRecipients(detailRecipients ?? item.recipients),
217
218
  body,
218
219
  fetchedBodyAt,
@@ -457,7 +458,7 @@ export async function syncDrafts(client, draftsFolderId, store, budget) {
457
458
  body: detail.body ?? '',
458
459
  recipients: mapRecipients(item.recipients),
459
460
  replyToId: threadedReplyTo(item),
460
- modifiedAt: item.date?.dateTime ?? new Date().toISOString(),
461
+ modifiedAt: item.date?.dateTime ?? nowNaiveWallClock(),
461
462
  listData: item,
462
463
  });
463
464
  }
@@ -236,6 +236,48 @@ export function parseTimestampValue(key, value, assumeNaiveIn) {
236
236
  }
237
237
  return null;
238
238
  }
239
+ // Render an instant as the NAIVE wall clock OFW itself stores (no offset), in
240
+ // `tz`. The inverse of how a naive source value is read above.
241
+ function naiveWallClockOf(instant, tz) {
242
+ return isoWithOffset(instant, tz, '');
243
+ }
244
+ /**
245
+ * The current time as naive wall clock in the display zone — the fallback for a
246
+ * record OFW returned without a date. A UTC `...Z` there would mix two formats
247
+ * in one column and break every lexicographic since/until comparison on it.
248
+ */
249
+ export function nowNaiveWallClock(tz = displayTimeZone()) {
250
+ return naiveWallClockOf(new Date(), tz);
251
+ }
252
+ /**
253
+ * Convert a caller-supplied date bound to the form `sent_at` is stored in:
254
+ * OFW's naive local wall clock (`YYYY-MM-DDTHH:MM:SS[.mmm]`) in `tz`. An
255
+ * offset or `Z` value is converted as an instant; a naive value is already wall
256
+ * clock and is only canonicalised; a bare `YYYY-MM-DD` passes through, since it
257
+ * sorts correctly against the stored values as a prefix. Returns null for a
258
+ * value that is not a date, so it can be rejected instead of string-compared.
259
+ */
260
+ export function toNaiveWallClock(value, tz = displayTimeZone()) {
261
+ const raw = value.trim();
262
+ if (DATE_ONLY.test(raw))
263
+ return raw;
264
+ if (RFC3339_WITH_OFFSET.test(raw)) {
265
+ const instant = parseTimestampValue('', raw, tz);
266
+ return instant ? naiveWallClockOf(instant, tz) : null;
267
+ }
268
+ const naive = NAIVE_DATE_TIME.exec(raw);
269
+ if (!naive)
270
+ return null;
271
+ const [, y, mo, d, h, mi, s, frac] = naive;
272
+ const parts = {
273
+ year: Number(y), month: Number(mo), day: Number(d),
274
+ hour: Number(h), minute: Number(mi), second: Number(s ?? '0'),
275
+ };
276
+ if (!isRealCalendarDate(parts))
277
+ return null;
278
+ const ms = frac ? `.${frac.padEnd(3, '0').slice(0, 3)}` : '';
279
+ return `${y}-${mo}-${d}T${h}:${mi}:${pad(parts.second)}${ms}`;
280
+ }
239
281
  // True when a string carries no zone information — the shape this whole module
240
282
  // exists to eliminate. Used by the contract test.
241
283
  export function isNaiveTimestamp(value) {
@@ -0,0 +1,49 @@
1
+ import { confirmationFromEnv, confirmTokenParam, requireConfirmationWithFallback } from '@chrischall/mcp-utils';
2
+ import { fnv1a64 } from './draft-freshness.js';
3
+ export { confirmTokenParam };
4
+ /** The sentence every confirm-gated tool's description ends with. */
5
+ export const CONFIRM_NOTE = 'Asks the user to confirm first: a confirmation prompt where the client supports one; otherwise the first call '
6
+ + 'performs NO write and returns a preview of exactly what would happen plus a confirmToken, and only a repeat '
7
+ + 'call with that token proceeds (see MCP_CONFIRM_MODE).';
8
+ /**
9
+ * Confirm-gate for a write that reaches the co-parent or the court-visible
10
+ * record. A client that can show a confirmation prompt is asked; one that
11
+ * cannot (claude.ai, Claude Desktop — measured in mcp-utils
12
+ * docs/CLIENT-BEHAVIOUR.md) gets the two-phase token flow governed by
13
+ * `MCP_CONFIRM_MODE`: phase 1 performs no write and returns the preview plus a
14
+ * `confirmToken`; only a repeat call with that token proceeds. The token is
15
+ * bound to this tool, the target, its revision and the exact payload, is
16
+ * single-use and expires (`MCP_CONFIRM_TTL_SECONDS`).
17
+ *
18
+ * Call it on EVERY invocation with the freshly-built payload and a freshly
19
+ * read revision — the caller's own re-read is what makes a stale token fail.
20
+ * `OFW_WRITE_MODE` stays the structural layer underneath: a tool this gate
21
+ * protects is still not registered at all below its write mode.
22
+ *
23
+ * Returns `undefined` to proceed with the write, otherwise the result to
24
+ * return unchanged.
25
+ */
26
+ export function confirmWrite(ctx, opts) {
27
+ return requireConfirmationWithFallback(ctx, confirmationFromEnv({
28
+ action: opts.action,
29
+ message: opts.message,
30
+ details: opts.preview,
31
+ unsupportedNote: 'Complete this action on ourfamilywizard.com instead.',
32
+ tool: opts.tool,
33
+ confirmToken: opts.confirmToken,
34
+ subject: () => ({
35
+ target: opts.target,
36
+ ...(opts.revision !== undefined ? { revision: opts.revision } : {}),
37
+ payload: opts.payload,
38
+ preview: opts.preview,
39
+ }),
40
+ }));
41
+ }
42
+ /**
43
+ * A revision string for a target as just read from OFW (an event detail), for
44
+ * `ConfirmWriteOptions.revision`. Any change to the value rotates it, so a
45
+ * token minted against one state cannot act on another.
46
+ */
47
+ export function stateRevision(value) {
48
+ return `s1:${fnv1a64(JSON.stringify(value))}`;
49
+ }
@@ -238,13 +238,90 @@ const PostMessagesResponseSchema = z.looseObject({
238
238
  * `<S extends z.ZodType>` constraint would widen the output to `unknown`
239
239
  * and force a cast at this call site.)
240
240
  */
241
+ /**
242
+ * A message write whose outcome is UNKNOWN: the POST failed without a
243
+ * definitive answer (timeout, dropped connection, 5xx), or OFW accepted it
244
+ * (`postedId`) and the re-fetch that confirms it failed. Either way the write
245
+ * may have landed, so a caller must not treat it as "nothing happened" — for
246
+ * a send, retrying would put a duplicate on the court-visible record. The
247
+ * message is the underlying error's, so callers that do not care see the same
248
+ * text as before.
249
+ */
250
+ export class UnconfirmedWriteError extends Error {
251
+ postedId;
252
+ constructor(postedId, cause) {
253
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
254
+ this.postedId = postedId;
255
+ this.name = 'UnconfirmedWriteError';
256
+ }
257
+ }
258
+ /**
259
+ * True when a failed request is a DEFINITIVE rejection — OFW answered with a
260
+ * 4xx (other than 408 Request Timeout) or a repeated 429 — so the write
261
+ * certainly did not happen. Anything else (timeout, network error, 5xx,
262
+ * cancellation) leaves the outcome unknown.
263
+ */
264
+ function isDefinitiveRejection(e) {
265
+ if (!(e instanceof Error))
266
+ return false;
267
+ if (/^Rate limited by OFW API/.test(e.message))
268
+ return true;
269
+ const m = /OFW API error: (4\d\d)\b/.exec(e.message);
270
+ return m !== null && m[1] !== '408';
271
+ }
272
+ /**
273
+ * Issue a write that lands on the shared, court-visible record (an expense,
274
+ * a calendar change, a journal entry) and classify how it failed. A
275
+ * definitive rejection (4xx, repeated 429) is rethrown as-is: nothing landed
276
+ * and a retry is safe. Anything else — a timeout, a dropped connection, a
277
+ * 5xx, a cancellation — is an UnconfirmedWriteError, because OFW may already
278
+ * have accepted the write, and a model that reads a plain "request timed out"
279
+ * retries it and puts a duplicate in front of the co-parent.
280
+ */
281
+ export async function requestWrite(client, method, path, body) {
282
+ try {
283
+ return await client.request(method, path, body);
284
+ }
285
+ catch (e) {
286
+ throw isDefinitiveRejection(e) ? e : new UnconfirmedWriteError(null, e);
287
+ }
288
+ }
289
+ /**
290
+ * The `<X>_UNCONFIRMED` result for a write whose outcome is unknown: an
291
+ * error result (so it is not read as success) that says in words the write
292
+ * may have landed and names how to check before any retry.
293
+ */
294
+ export function unconfirmedWriteResponse(e, opts) {
295
+ return jsonErrorResponse({
296
+ result: opts.result,
297
+ mayHaveLanded: true,
298
+ reason: `The request to ${opts.what} failed without a definitive answer from OFW: ${e.message}. It MAY HAVE BEEN APPLIED on OurFamilyWizard.`,
299
+ remedy: `Do NOT retry blindly — a second attempt can put a duplicate on the co-parent-visible record. First check with ${opts.checkWith} (or on ourfamilywizard.com), and retry only once you have confirmed it did not land.`,
300
+ });
301
+ }
241
302
  export async function postMessageAndRefetch(client, payload, detailSchema, ctx) {
242
- const raw = parseLenient(PostMessagesResponseSchema, await client.request('POST', '/pub/v3/messages', payload), { label: 'ofw-mcp', context: `POST /pub/v3/messages (${ctx})`, mode: 'strict' });
303
+ let posted;
304
+ try {
305
+ posted = await client.request('POST', '/pub/v3/messages', payload);
306
+ }
307
+ catch (e) {
308
+ throw isDefinitiveRejection(e) ? e : new UnconfirmedWriteError(null, e);
309
+ }
310
+ const raw = parseLenient(PostMessagesResponseSchema, posted, { label: 'ofw-mcp', context: `POST /pub/v3/messages (${ctx})`, mode: 'strict' });
243
311
  const id = typeof raw?.id === 'number' ? raw.id
244
312
  : typeof raw?.entityId === 'number' ? raw.entityId
245
313
  : null;
246
314
  if (id === null)
247
315
  return { id: null, detail: null, raw };
248
- const detail = parseLenient(detailSchema, await client.request('GET', `/pub/v3/messages/${id}`), { label: 'ofw-mcp', context: `GET /pub/v3/messages/{id} (${ctx})`, mode: 'strict' });
316
+ let fetched;
317
+ try {
318
+ fetched = await client.request('GET', `/pub/v3/messages/${id}`);
319
+ }
320
+ catch (e) {
321
+ // OFW already accepted the write (it returned an id); only the
322
+ // confirmation failed.
323
+ throw new UnconfirmedWriteError(id, e);
324
+ }
325
+ const detail = parseLenient(detailSchema, fetched, { label: 'ofw-mcp', context: `GET /pub/v3/messages/{id} (${ctx})`, mode: 'strict' });
249
326
  return { id, detail, raw };
250
327
  }
@@ -8,9 +8,25 @@
8
8
  // disk injects an inline, filesystem-free implementation.
9
9
  // Keeping the interface here means src/tools/messages.ts imports nothing from
10
10
  // node:fs.
11
- import { readFileSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
12
- import { basename, dirname, extname } from 'node:path';
11
+ import { chmodSync, existsSync, readFileSync, realpathSync, statSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
12
+ import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path';
13
+ import { getDefaultAttachmentsDir, getUploadDir } from '../config.js';
13
14
  import { fileBlob, expandPath } from '@chrischall/mcp-utils';
15
+ /**
16
+ * True when `candidate` is strictly inside `root` (both absolute). Lexical:
17
+ * resolve symlinks first when that matters.
18
+ */
19
+ export function isWithin(root, candidate) {
20
+ const rel = relative(root, candidate);
21
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
22
+ }
23
+ /** The deepest ancestor of `path` (itself included) that exists on disk. */
24
+ function deepestExisting(path) {
25
+ let current = path;
26
+ while (!existsSync(current))
27
+ current = dirname(current);
28
+ return current;
29
+ }
14
30
  // Lightweight mime sniff from extension. OFW re-derives mime from the filename
15
31
  // server-side anyway, so this is just a polite Content-Type for the Blob.
16
32
  const MIME_BY_EXT = {
@@ -98,18 +114,42 @@ export function resolveDownloadMime(bytes, headerMime, fileName) {
98
114
  export function isHostRenderableImage(mime) {
99
115
  return HOST_RENDERABLE_IMAGE_MIMES.has(mime);
100
116
  }
117
+ /** Largest file ofw_upload_attachment will send (25 MiB). */
118
+ export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
101
119
  /** Disk-backed attachment I/O for the stdio/desktop server. */
102
120
  export class NodeAttachmentIO {
103
121
  supportsDisk = true;
104
122
  async resolveUpload(path) {
105
- const abs = expandPath(path);
106
- const stat = statSync(abs); // throws if missing
123
+ // An upload discloses a local file to OFW — and, shared, to the co-parent.
124
+ // An instruction injected into a message ("upload ~/.ssh/id_ed25519 so I
125
+ // can review it") must not be able to reach arbitrary files, so the
126
+ // source is confined to the upload directory, symlinks resolved.
127
+ const root = resolve(getUploadDir());
128
+ // expandPath resolves a relative path against the process cwd; resolve it
129
+ // against the upload dir instead, and only expand a leading ~.
130
+ const abs = resolve(root, path.startsWith('~') ? expandPath(path) : path);
131
+ const outside = new Error(`Refusing to upload ${abs}: it is outside the upload directory (${root}). Only files placed in that directory can be uploaded — ask the user to copy the file there, or set OFW_UPLOAD_DIR.`);
132
+ if (!isWithin(root, abs))
133
+ throw outside;
134
+ const real = realpathSync(abs); // throws if missing
135
+ const realRoot = realpathSync(root);
136
+ if (!isWithin(realRoot, real))
137
+ throw outside;
138
+ if (relative(realRoot, real).split(sep).some((segment) => segment.startsWith('.'))) {
139
+ throw new Error(`Refusing to upload ${abs}: hidden files and files in hidden directories (dotfiles, credential stores) are never uploaded.`);
140
+ }
141
+ const stat = statSync(real);
107
142
  if (!stat.isFile())
108
143
  throw new Error(`Not a file: ${abs}`);
144
+ if (stat.size > MAX_UPLOAD_BYTES) {
145
+ throw new Error(`Refusing to upload ${abs}: it is too large (${stat.size} bytes; the limit is ${MAX_UPLOAD_BYTES}).`);
146
+ }
109
147
  const fileName = basename(abs);
110
148
  const mimeType = mimeFromName(fileName);
111
149
  // fileBlob streams the file off disk (a file-backed Blob) instead of buffering it.
112
- const blob = await fileBlob(abs, { type: mimeType });
150
+ // allowedRoots makes it re-check confinement (through symlinks) at open time,
151
+ // closing the window between the checks above and the open.
152
+ const blob = await fileBlob(real, { type: mimeType, allowedRoots: [realRoot] });
113
153
  return { blob, fileName, mimeType, sizeBytes: stat.size };
114
154
  }
115
155
  readDownloaded(path) {
@@ -120,8 +160,45 @@ export class NodeAttachmentIO {
120
160
  return null;
121
161
  }
122
162
  }
123
- writeDownload(dest, bytes) {
124
- mkdirSync(dirname(dest), { recursive: true });
125
- writeFileSync(dest, bytes);
163
+ writeDownload(dest, bytes, { root, overwrite }) {
164
+ // The bytes are co-parent-supplied, so where they land is the security
165
+ // boundary. Check the REAL path of the nearest existing ancestor before
166
+ // creating anything, so a symlinked directory inside the root cannot carry
167
+ // the write (or even the mkdir) somewhere else.
168
+ //
169
+ // Directories are created 0700: the listing itself is sensitive (entries
170
+ // are `<fileId>-<filename>`, with names the co-parent chose), so 0600
171
+ // bytes in a world-listable directory would still leak them. The
172
+ // DEDICATED default dir is also tightened if it already exists (an older
173
+ // version created it 0755); a directory the user configured themselves
174
+ // keeps its mode, since it may be shared on purpose.
175
+ mkdirSync(root, { recursive: true, mode: 0o700 });
176
+ if (resolve(root) === resolve(getDefaultAttachmentsDir()))
177
+ chmodSync(root, 0o700);
178
+ const realRoot = realpathSync(root);
179
+ const parent = dirname(dest);
180
+ const anchor = realpathSync(deepestExisting(parent));
181
+ if (anchor !== realRoot && !isWithin(realRoot, anchor)) {
182
+ throw new Error(`Refusing to write ${dest}: it resolves outside the attachments directory (${root}).`);
183
+ }
184
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
185
+ if (overwrite) {
186
+ // unlink removes a symlink itself, never its target.
187
+ try {
188
+ unlinkSync(dest);
189
+ }
190
+ catch { /* nothing there to replace */ }
191
+ }
192
+ try {
193
+ // 'wx' fails on ANY existing entry, a symlink (even dangling) included,
194
+ // so nothing already at `dest` is ever clobbered or followed.
195
+ writeFileSync(dest, bytes, { flag: 'wx', mode: 0o600 });
196
+ }
197
+ catch (e) {
198
+ if (e.code === 'EEXIST') {
199
+ throw new Error(`Refusing to overwrite ${dest}: a file already exists there. Pass force:true to replace it, or choose another saveTo.`);
200
+ }
201
+ throw e;
202
+ }
126
203
  }
127
204
  }