@nimbus-dev/connectors 0.4.0 → 0.5.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.
@@ -43,15 +43,59 @@ const listArgs = z.object({
43
43
  calendar: z.string().min(1).optional(),
44
44
  });
45
45
 
46
+ /** A C0 control (CR, LF and TAB included), DEL, or a C1 control. */
47
+ function isControlCode(code: number): boolean {
48
+ return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
49
+ }
50
+
51
+ /**
52
+ * Whether `value` carries a control character anywhere. Event text reaches iCalendar content
53
+ * lines, where CR or LF ends a line and starts a new property; the SDK's escaping turns CRLF or LF
54
+ * into an escaped newline but leaves a bare CR. So the input is REFUSED here rather than relying
55
+ * on (or adding) an escape. Nothing is stripped.
56
+ */
57
+ export function hasControlCharacter(value: string): boolean {
58
+ for (let i = 0; i < value.length; i++) {
59
+ if (isControlCode(value.charCodeAt(i))) return true;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ /** Whether `value` carries any whitespace (Unicode `\s`) or control character. */
65
+ export function hasWhitespaceOrControl(value: string): boolean {
66
+ return /\s/u.test(value) || hasControlCharacter(value);
67
+ }
68
+
69
+ export const ICLOUD_TEXT_REFUSAL = "must not contain a line break or other control character";
70
+ export const ICLOUD_TIME_REFUSAL =
71
+ "must be a UTC time in the form YYYYMMDDTHHMMSSZ; all-day and floating times are not supported";
72
+ export const ICLOUD_UID_REFUSAL =
73
+ "must be 1 to 200 characters, each a letter, a digit, '.', '_', '@' or '-'";
74
+ export const ICLOUD_ATTENDEE_REFUSAL =
75
+ "each attendee must be one email address, with no whitespace or control character";
76
+
77
+ /** `YYYYMMDDTHHMMSSZ`: a UTC date-time and nothing else (owner ruling OR-B4-6). */
78
+ const UTC_DATE_TIME = /^\d{8}T\d{6}Z$/;
79
+ const EVENT_UID = /^[A-Za-z0-9._@-]{1,200}$/;
80
+
81
+ /** A free-text event field: refused, never cleaned, when it carries a control character. */
82
+ function eventText(): z.ZodString {
83
+ return z.string().refine((s) => !hasControlCharacter(s), ICLOUD_TEXT_REFUSAL);
84
+ }
85
+
46
86
  const createArgs = z.object({
47
- calendar: z.string().min(1).optional(),
48
- summary: z.string().min(1).max(1000),
49
- start: z.string().min(1),
50
- end: z.string().min(1),
51
- description: z.string().max(10_000).optional(),
52
- location: z.string().max(500).optional(),
53
- attendees: z.array(z.email()).optional(),
54
- uid: z.string().min(1).optional(),
87
+ calendar: eventText().min(1).optional(),
88
+ summary: eventText().min(1).max(1000),
89
+ start: z.string().regex(UTC_DATE_TIME, ICLOUD_TIME_REFUSAL),
90
+ end: z.string().regex(UTC_DATE_TIME, ICLOUD_TIME_REFUSAL),
91
+ description: eventText().max(10_000).optional(),
92
+ location: eventText().max(500).optional(),
93
+ // z.email() already refuses whitespace and controls today; the explicit check keeps that a
94
+ // stated property of THIS schema, not of zod's current email pattern.
95
+ attendees: z
96
+ .array(z.email().refine((s) => !hasWhitespaceOrControl(s), ICLOUD_ATTENDEE_REFUSAL))
97
+ .optional(),
98
+ uid: z.string().regex(EVENT_UID, ICLOUD_UID_REFUSAL).optional(),
55
99
  });
56
100
 
57
101
  const deleteArgs = z.object({
@@ -199,7 +243,7 @@ export function registerAppleCalendarTools(
199
243
  recoverable: true,
200
244
  scopeTargetOf: (p) => ({ kind: "calendar", value: p.calendar ?? "default" }),
201
245
  },
202
- "Create a new event in iCloud Calendar via CalDAV PUT.",
246
+ "Create a new event in iCloud Calendar via CalDAV PUT. start and end are UTC, in the form YYYYMMDDTHHMMSSZ (all-day and floating times are refused). summary, description, location and calendar may not contain a line break or other control character; uid, when given, is 1-200 of A-Z a-z 0-9 . _ @ -.",
203
247
  createArgs,
204
248
  async (parsedData) => {
205
249
  const parsed = { success: true as const, data: parsedData };
@@ -71,6 +71,37 @@ function readPageSnapshot(res: { ok: boolean; status: number; text: string }): P
71
71
  };
72
72
  }
73
73
 
74
+ /** One entry of `confluence_page_get`'s `ancestors`: what a caller needs, not the whole page. */
75
+ export interface PageAncestor {
76
+ readonly id: string | null;
77
+ readonly title: string | null;
78
+ }
79
+
80
+ /**
81
+ * Narrow the `ancestors` Confluence expands on a page to `{ id, title }` per entry, in Confluence's
82
+ * order (the space's top-level page first, the page's direct parent last). An ancestor is a whole
83
+ * content object (links, extensions, status); a caller deciding where a page sits needs only its
84
+ * id, and the title is free. An entry without a string id keeps its place with `id: null` rather
85
+ * than being dropped, so the chain is never shorter than Confluence said. A reply with no
86
+ * `ancestors` array is returned as it came: absent stays absent, never an invented `[]`.
87
+ */
88
+ export function withAncestorSummaries(page: unknown): unknown {
89
+ if (page === null || typeof page !== "object" || Array.isArray(page)) return page;
90
+ const obj = page as Record<string, unknown>;
91
+ const ancestors = obj["ancestors"];
92
+ if (!Array.isArray(ancestors)) return page;
93
+ const summaries: PageAncestor[] = ancestors.map((a: unknown) => {
94
+ const entry = a !== null && typeof a === "object" ? (a as Record<string, unknown>) : {};
95
+ const id = entry["id"];
96
+ const title = entry["title"];
97
+ return {
98
+ id: typeof id === "string" ? id : null,
99
+ title: typeof title === "string" ? title : null,
100
+ };
101
+ });
102
+ return { ...obj, ancestors: summaries };
103
+ }
104
+
74
105
  /** Tool names exposed by this connector — for contract/introspection tests. */
75
106
  export const CONFLUENCE_TOOL_NAMES = [
76
107
  "confluence_space_list",
@@ -150,14 +181,23 @@ export function registerConfluenceTools(
150
181
 
151
182
  reg(
152
183
  "confluence_page_get",
153
- "Get a Confluence page with body.storage (GET /content/{id}).",
184
+ "Get a Confluence page with body.storage (GET /content/{id}). ancestors lists the pages above it as { id, title }, the top-level page first and the direct parent last.",
154
185
  confluencePageIdSchema,
155
186
  async (parsed) => {
156
187
  const id = encodeURIComponent(parsed.pageId);
157
188
  const res = await confFetch(
158
- `/content/${id}?expand=body.storage,version,history.lastUpdated,space`,
189
+ `/content/${id}?expand=body.storage,version,history.lastUpdated,space,ancestors`,
159
190
  );
160
- return mcpJsonResultFromTextIfOk("Confluence", res);
191
+ if (!res.ok) {
192
+ throw new Error(`Confluence ${String(res.status)}: ${res.text.slice(0, 400)}`);
193
+ }
194
+ let page: unknown;
195
+ try {
196
+ page = JSON.parse(res.text) as unknown;
197
+ } catch {
198
+ throw new Error("Confluence: invalid JSON response");
199
+ }
200
+ return mcpJsonResult(withAncestorSummaries(page));
161
201
  },
162
202
  );
163
203
 
@@ -2,7 +2,7 @@
2
2
  "id": "com.nimbus.obsidian",
3
3
  "displayName": "Obsidian",
4
4
  "version": "0.1.0",
5
- "description": "Local Obsidian vault connector (notes, backlinks). No network. Vault paths come from [[filesystem.roots]] and are merged into permissions.filesystem at spawn time by the Gateway. obsidian_append_to_daily_note requires HITL.",
5
+ "description": "Local Obsidian vault connector (notes, backlinks, daily notes). No network. Vault paths come from [[filesystem.roots]] and are merged into permissions.filesystem.read at spawn time by the Gateway, which may also add the vaults to permissions.filesystem.write at spawn (only while the owner has approved obsidian_append_to_daily_note for the model). obsidian_append_to_daily_note requires HITL; obsidian_daily_note_get is a read.",
6
6
  "author": "Nimbus",
7
7
  "entrypoint": "dist/server.js",
8
8
  "runtime": "bun",
@@ -2,11 +2,13 @@ import { createHash } from "node:crypto";
2
2
  import {
3
3
  closeSync,
4
4
  fstatSync,
5
+ lstatSync,
5
6
  mkdirSync,
6
7
  openSync,
7
8
  readdirSync,
8
9
  readFileSync,
9
10
  readSync,
11
+ realpathSync,
10
12
  statSync,
11
13
  writeSync,
12
14
  } from "node:fs";
@@ -54,6 +56,93 @@ function assertWithinVault(vaultRoot: string, relPath: string): string {
54
56
  return candidate;
55
57
  }
56
58
 
59
+ /** Whether anything, a dangling link included, sits at `p`. */
60
+ function pathEntryExists(p: string): boolean {
61
+ try {
62
+ lstatSync(p);
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Refuse `abs` (a path `assertWithinVault` already accepted) when the place it really names is
71
+ * outside the vault: a folder or note that is, or passes through, a symlink or junction out of the
72
+ * vault. `assertWithinVault` compares strings, so it cannot see a link.
73
+ *
74
+ * The note, and the folders above it, may not exist yet (the append creates them), so the check
75
+ * resolves the deepest part of the path that does exist; whatever is created below it is created
76
+ * inside it. A dangling link cannot be resolved and is refused. The check and the later open are
77
+ * two system calls, so a link swapped in between them is not seen here: the gateway's sandbox,
78
+ * which grants only the vaults, is the boundary (stated bound).
79
+ */
80
+ export function assertRealpathWithinVault(vaultRoot: string, abs: string, relPath: string): void {
81
+ const realRoot = realpathSync(resolve(vaultRoot));
82
+ const prefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
83
+ let probe = abs;
84
+ while (!pathEntryExists(probe)) {
85
+ const parent = dirname(probe);
86
+ if (parent === probe) break;
87
+ probe = parent;
88
+ }
89
+ let real: string;
90
+ try {
91
+ real = realpathSync(probe);
92
+ } catch {
93
+ throw new Error(`Path escapes vault: ${relPath}`);
94
+ }
95
+ if (real !== realRoot && !real.startsWith(prefix)) {
96
+ throw new Error(`Path escapes vault: ${relPath}`);
97
+ }
98
+ }
99
+
100
+ /** How much of the end of a daily note `obsidian_daily_note_get` returns. */
101
+ export const DAILY_NOTE_TAIL_BYTES = 16 * 1024;
102
+
103
+ /** The positioned-read signature of `readSync`, injectable so a test can count what is read. */
104
+ export type PositionedRead = (
105
+ fd: number,
106
+ buffer: Buffer,
107
+ offset: number,
108
+ length: number,
109
+ position: number,
110
+ ) => number;
111
+
112
+ /**
113
+ * The size of the file at `abs` and its last `maxBytes` bytes, decoded as UTF-8.
114
+ *
115
+ * Positioned reads of at most `maxBytes` in all, never the whole file. When the window starts
116
+ * inside the file, the UTF-8 continuation bytes it begins with (the rest of a character the window
117
+ * cut) are dropped, so the tail starts on a character boundary.
118
+ */
119
+ export function readUtf8Tail(
120
+ abs: string,
121
+ maxBytes: number,
122
+ read: PositionedRead = readSync,
123
+ ): { size: number; tail: string } {
124
+ const fd = openSync(abs, "r");
125
+ try {
126
+ const size = fstatSync(fd).size;
127
+ const start = Math.max(0, size - maxBytes);
128
+ const length = size - start;
129
+ const buf = Buffer.alloc(length);
130
+ let got = 0;
131
+ while (got < length) {
132
+ const n = read(fd, buf, got, length - got, start + got);
133
+ if (n <= 0) break;
134
+ got += n;
135
+ }
136
+ let skip = 0;
137
+ if (start > 0) {
138
+ while (skip < 3 && skip < got && ((buf[skip] ?? 0) & 0xc0) === 0x80) skip++;
139
+ }
140
+ return { size, tail: buf.subarray(skip, got).toString("utf8") };
141
+ } finally {
142
+ closeSync(fd);
143
+ }
144
+ }
145
+
57
146
  function loadVaultPaths(): readonly string[] {
58
147
  const raw = requireProcessEnv("OBSIDIAN_VAULT_PATHS_JSON");
59
148
  let parsed: unknown;
@@ -98,7 +187,10 @@ function walkForVaults(dir: string, out: VaultEntry[]): void {
98
187
  const sub = join(dir, e);
99
188
  let isDir = false;
100
189
  try {
101
- const st = statSync(sub);
190
+ // lstat, not stat: stat follows a link, so its isSymbolicLink() was always false and
191
+ // discovery used to find a vault through a symlink or junction outside the configured root.
192
+ // Every link below a root is skipped, so a discovered vault is physically under its root.
193
+ const st = lstatSync(sub);
102
194
  isDir = st.isDirectory() && !st.isSymbolicLink();
103
195
  } catch {
104
196
  continue;
@@ -128,7 +220,9 @@ function walkNotes(currentDir: string, vaultRoot: string, out: string[]): void {
128
220
  let isFile = false;
129
221
  let isDir = false;
130
222
  try {
131
- const st = statSync(full);
223
+ // lstat, not stat: stat follows a link, so its isSymbolicLink() is always false and the
224
+ // walk used to list notes through a symlink or junction, out of the vault included.
225
+ const st = lstatSync(full);
132
226
  if (st.isSymbolicLink()) continue;
133
227
  isFile = st.isFile();
134
228
  isDir = st.isDirectory();
@@ -151,6 +245,7 @@ function readNote(
151
245
  relPath: string,
152
246
  ): { title: string; body: string; raw: string } {
153
247
  const abs = assertWithinVault(vaultRoot, relPath);
248
+ assertRealpathWithinVault(vaultRoot, abs, relPath);
154
249
  const raw = readFileSync(abs, "utf8");
155
250
  const m = FRONTMATTER_RE.exec(raw);
156
251
  const body = m === null ? raw : raw.slice(m[0].length);
@@ -193,6 +288,7 @@ export const OBSIDIAN_TOOL_NAMES = [
193
288
  "obsidian_get",
194
289
  "obsidian_search",
195
290
  "obsidian_append_to_daily_note",
291
+ "obsidian_daily_note_get",
196
292
  ] as const;
197
293
 
198
294
  /** The date tokens an Obsidian daily-note filename format may contain. */
@@ -400,6 +496,28 @@ export function registerObsidianTools(
400
496
  }),
401
497
  );
402
498
 
499
+ /**
500
+ * The vault and the daily note a daily-note tool names: ONE resolution, shared by the append and
501
+ * the read, so the read shows exactly the note the append would write. String containment
502
+ * (`assertWithinVault`) and then realpath containment (`assertRealpathWithinVault`).
503
+ */
504
+ function resolveDailyNote(parsed: { vault_id: string; date_iso?: string | undefined }): {
505
+ v: VaultEntry;
506
+ rel: string;
507
+ abs: string;
508
+ } {
509
+ const v = findVaultByIdOrPathPrefix(VAULTS, parsed.vault_id);
510
+ if (v === undefined) {
511
+ throw new Error("Unknown vault_id");
512
+ }
513
+ const date =
514
+ parsed.date_iso === undefined ? new Date() : new Date(`${parsed.date_iso}T00:00:00Z`);
515
+ const rel = resolveDailyNoteRelativePath(v.root, date);
516
+ const abs = assertWithinVault(v.root, rel);
517
+ assertRealpathWithinVault(v.root, abs, rel);
518
+ return { v, rel, abs };
519
+ }
520
+
403
521
  const appendDailyNoteSchema = z.object({
404
522
  vault_id: z.string().min(1),
405
523
  content: z.string().min(1),
@@ -420,15 +538,10 @@ export function registerObsidianTools(
420
538
  "Append text to today's Obsidian daily note. Creates the file if it does not exist. Always appends — never overwrites. Adds a leading newline when the existing file does not end in one. Requires HITL `obsidian.note.append`.",
421
539
  appendDailyNoteSchema,
422
540
  syncToolHandler((parsed) => {
423
- const v = findVaultByIdOrPathPrefix(VAULTS, parsed.vault_id);
424
- if (v === undefined) {
425
- throw new Error("Unknown vault_id");
426
- }
427
- const date =
428
- parsed.date_iso === undefined ? new Date() : new Date(`${parsed.date_iso}T00:00:00Z`);
429
- const rel = resolveDailyNoteRelativePath(v.root, date);
430
- const abs = assertWithinVault(v.root, rel);
541
+ const { v, rel, abs } = resolveDailyNote(parsed);
431
542
  mkdirSync(dirname(abs), { recursive: true });
543
+ // Again, now the folders exist: what was created must still be inside the vault.
544
+ assertRealpathWithinVault(v.root, abs, rel);
432
545
 
433
546
  const fd = openSync(abs, "a+");
434
547
  let bytes = 0;
@@ -463,4 +576,39 @@ export function registerObsidianTools(
463
576
  });
464
577
  }),
465
578
  );
579
+
580
+ const dailyNoteGetSchema = z.object({
581
+ vault_id: z.string().min(1),
582
+ date_iso: z
583
+ .string()
584
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
585
+ .optional(),
586
+ });
587
+
588
+ reg(
589
+ "obsidian_daily_note_get",
590
+ `Read the end of the Obsidian daily note that obsidian_append_to_daily_note would write for the same vault_id and date_iso (today in UTC when omitted). Returns { vault_id, vault_name, path, exists, size_bytes, tail }: tail is at most the last ${String(DAILY_NOTE_TAIL_BYTES)} bytes, starting on a character boundary; a missing note is exists: false, size_bytes: 0, tail "". Read-only.`,
591
+ dailyNoteGetSchema,
592
+ syncToolHandler((parsed) => {
593
+ const { v, rel, abs } = resolveDailyNote(parsed);
594
+ let exists = false;
595
+ let size = 0;
596
+ let tail = "";
597
+ if (pathEntryExists(abs)) {
598
+ if (!statSync(abs).isFile()) {
599
+ throw new Error(`Daily note is not a file: ${rel}`);
600
+ }
601
+ exists = true;
602
+ ({ size, tail } = readUtf8Tail(abs, DAILY_NOTE_TAIL_BYTES));
603
+ }
604
+ return jsonResult({
605
+ vault_id: v.id,
606
+ vault_name: v.name,
607
+ path: rel,
608
+ exists,
609
+ size_bytes: size,
610
+ tail,
611
+ });
612
+ }),
613
+ );
466
614
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nimbus-dev/connectors",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },