@jini-ai/platform 0.3.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.
Files changed (127) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +121 -2
  3. package/dist/asset-cache.d.ts +0 -22
  4. package/dist/asset-cache.js +3 -138
  5. package/dist/download.js +2 -2
  6. package/dist/fs/atomic-write.d.ts +103 -0
  7. package/dist/fs/atomic-write.js +201 -0
  8. package/dist/fs/durable-json.d.ts +89 -0
  9. package/dist/fs/durable-json.js +166 -0
  10. package/dist/fs/env-text.d.ts +11 -0
  11. package/dist/fs/env-text.js +22 -0
  12. package/dist/fs/file-lock.d.ts +126 -0
  13. package/dist/fs/file-lock.js +306 -0
  14. package/dist/fs/guarded-reader.d.ts +60 -0
  15. package/dist/fs/guarded-reader.js +310 -0
  16. package/dist/fs/index.d.ts +8 -0
  17. package/dist/fs/index.js +9 -0
  18. package/dist/fs/strict-containment.d.ts +5 -0
  19. package/dist/fs/strict-containment.js +13 -0
  20. package/dist/fs.js +2 -3
  21. package/dist/http/guarded/client.d.ts +13 -0
  22. package/dist/http/guarded/client.js +240 -0
  23. package/dist/http/guarded/errors.d.ts +21 -0
  24. package/dist/http/guarded/errors.js +20 -0
  25. package/dist/http/guarded/fetch-adapter.d.ts +23 -0
  26. package/dist/http/guarded/fetch-adapter.js +30 -0
  27. package/dist/http/guarded/index.d.ts +12 -0
  28. package/dist/http/guarded/index.js +7 -0
  29. package/dist/http/guarded/node-ports.d.ts +8 -0
  30. package/dist/http/guarded/node-ports.js +11 -0
  31. package/dist/http/guarded/ports.d.ts +51 -0
  32. package/dist/http/guarded/ports.js +2 -0
  33. package/dist/http/guarded/transport.fetch.d.ts +11 -0
  34. package/dist/http/guarded/transport.fetch.js +109 -0
  35. package/dist/http/guarded/types.d.ts +8 -0
  36. package/dist/http/guarded/types.js +2 -0
  37. package/dist/index.d.ts +61 -1
  38. package/dist/index.js +61 -1
  39. package/dist/mail/adapter-module.d.ts +20 -0
  40. package/dist/mail/adapter-module.js +2 -0
  41. package/dist/mail/delivery-availability.d.ts +7 -0
  42. package/dist/mail/delivery-availability.js +6 -0
  43. package/dist/mail/index.d.ts +7 -0
  44. package/dist/mail/index.js +3 -0
  45. package/dist/mail/ports.d.ts +43 -0
  46. package/dist/mail/ports.js +2 -0
  47. package/dist/mail/purpose-scoped-mailer.d.ts +18 -0
  48. package/dist/mail/purpose-scoped-mailer.js +50 -0
  49. package/dist/mail/smtp.d.ts +79 -0
  50. package/dist/mail/smtp.js +102 -0
  51. package/dist/mail/types.d.ts +83 -0
  52. package/dist/mail/types.js +2 -0
  53. package/dist/messages.d.ts +22 -0
  54. package/dist/messages.js +15 -0
  55. package/dist/net/address.d.ts +38 -0
  56. package/dist/net/address.js +241 -0
  57. package/dist/net/index.d.ts +2 -0
  58. package/dist/net/index.js +2 -0
  59. package/dist/process.d.ts +2 -1
  60. package/dist/process.js +2 -1
  61. package/dist/proxy-env.js +1 -1
  62. package/dist/resource-paths.js +1 -1
  63. package/dist/sandbox-env.d.ts +1 -1
  64. package/dist/sandbox-env.js +1 -1
  65. package/dist/secrets/aad.d.ts +7 -0
  66. package/dist/secrets/aad.js +5 -0
  67. package/dist/secrets/credential-sets/aad.d.ts +7 -0
  68. package/dist/secrets/credential-sets/aad.js +7 -0
  69. package/dist/secrets/credential-sets/index.d.ts +4 -0
  70. package/dist/secrets/credential-sets/index.js +3 -0
  71. package/dist/secrets/credential-sets/repo.memory.d.ts +36 -0
  72. package/dist/secrets/credential-sets/repo.memory.js +72 -0
  73. package/dist/secrets/credential-sets/types.d.ts +67 -0
  74. package/dist/secrets/credential-sets/types.js +6 -0
  75. package/dist/secrets/index.d.ts +9 -0
  76. package/dist/secrets/index.js +5 -0
  77. package/dist/secrets/keyring.env.d.ts +176 -0
  78. package/dist/secrets/keyring.env.js +271 -0
  79. package/dist/secrets/keyring.memory.d.ts +10 -0
  80. package/dist/secrets/keyring.memory.js +9 -0
  81. package/dist/secrets/ports.d.ts +33 -0
  82. package/dist/secrets/ports.js +2 -0
  83. package/dist/secrets/secret-sealer.aesgcm.d.ts +22 -0
  84. package/dist/secrets/secret-sealer.aesgcm.js +64 -0
  85. package/dist/secrets/testing.d.ts +4 -0
  86. package/dist/secrets/testing.js +4 -0
  87. package/dist/secrets/types.d.ts +10 -0
  88. package/dist/secrets/types.js +2 -0
  89. package/dist/shell.d.ts +10 -6
  90. package/dist/shell.js +10 -6
  91. package/dist/terminal.d.ts +2 -2
  92. package/dist/terminal.js +9 -5
  93. package/package.json +79 -4
  94. package/dist/asset-cache.d.ts.map +0 -1
  95. package/dist/asset-cache.js.map +0 -1
  96. package/dist/aws-sigv4.d.ts.map +0 -1
  97. package/dist/aws-sigv4.js.map +0 -1
  98. package/dist/blob-storage.d.ts.map +0 -1
  99. package/dist/blob-storage.js.map +0 -1
  100. package/dist/command.d.ts.map +0 -1
  101. package/dist/command.js.map +0 -1
  102. package/dist/download.d.ts.map +0 -1
  103. package/dist/download.js.map +0 -1
  104. package/dist/fetch-with-timeout.d.ts.map +0 -1
  105. package/dist/fetch-with-timeout.js.map +0 -1
  106. package/dist/fs.d.ts.map +0 -1
  107. package/dist/fs.js.map +0 -1
  108. package/dist/home-expansion.d.ts.map +0 -1
  109. package/dist/home-expansion.js.map +0 -1
  110. package/dist/http.d.ts.map +0 -1
  111. package/dist/http.js.map +0 -1
  112. package/dist/index.d.ts.map +0 -1
  113. package/dist/index.js.map +0 -1
  114. package/dist/process.d.ts.map +0 -1
  115. package/dist/process.js.map +0 -1
  116. package/dist/proxy-env.d.ts.map +0 -1
  117. package/dist/proxy-env.js.map +0 -1
  118. package/dist/resource-paths.d.ts.map +0 -1
  119. package/dist/resource-paths.js.map +0 -1
  120. package/dist/sandbox-env.d.ts.map +0 -1
  121. package/dist/sandbox-env.js.map +0 -1
  122. package/dist/shell.d.ts.map +0 -1
  123. package/dist/shell.js.map +0 -1
  124. package/dist/terminal.d.ts.map +0 -1
  125. package/dist/terminal.js.map +0 -1
  126. package/dist/toolchain.d.ts.map +0 -1
  127. package/dist/toolchain.js.map +0 -1
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Atomic writes prevent tears but not lost updates between shared read-modify-writes. Plain readers
3
+ * need no lock. Deliberately non-reentrant: nesting times out rather than silently sharing an
4
+ * exclusive section; compose store operations from unlocked internals instead.
5
+ * Kernel exclusive create chooses the winner. A unique token AND inode identify that owner;
6
+ * pid alone cannot distinguish a later owner after a stale break. Signal-zero liveness probes
7
+ * treat only ESRCH as death: permission failure must never authorize evicting a live process.
8
+ * Stale removal rechecks inode, bytes and mtime immediately before unlink. There is still a residual
9
+ * check-to-unlink race on filesystems without conditional unlink; rename/restore chains create wider
10
+ * multi-process races and are deliberately avoided. Atomic file replacement independently prevents
11
+ * torn content. Hosts whose operations can legitimately exceed the stale budget use staleMs: Infinity.
12
+ */
13
+ import fs from "node:fs";
14
+ import * as asyncFs from "node:fs/promises";
15
+ import { randomUUID } from "node:crypto";
16
+ import { hostname as nodeHostname } from "node:os";
17
+ import path from "node:path";
18
+ import { createSystemClock } from "@jini-ai/core/primitives";
19
+ import { defaultPlatformMessages } from "../messages.js";
20
+ export const DEFAULT_LOCK_TIMEOUT_MS = 15_000;
21
+ export const DEFAULT_LOCK_STALE_AFTER_MS = 10_000;
22
+ export class FileLockTimeoutError extends Error {
23
+ lockPath;
24
+ holder;
25
+ waitedMs;
26
+ constructor(required, optional = {}) {
27
+ super(optional.message ?? (optional.messages ?? defaultPlatformMessages).fileLockTimeout({ waitedMs: required.waitedMs }));
28
+ this.name = "FileLockTimeoutError";
29
+ this.lockPath = required.lockPath;
30
+ this.holder = required.holder;
31
+ this.waitedMs = required.waitedMs;
32
+ }
33
+ }
34
+ export class FileLockLostError extends Error {
35
+ lockPath;
36
+ constructor({ lockPath }, { messages = defaultPlatformMessages } = {}) {
37
+ super(messages.fileLockLost());
38
+ this.name = "FileLockLostError";
39
+ this.lockPath = lockPath;
40
+ }
41
+ }
42
+ /** Platform-specific contention classification retains the Windows exclusive-open behavior. */
43
+ export function isContendedLockError({ code, platform }) {
44
+ return code === "EEXIST" || (code === "EPERM" && platform === "win32");
45
+ }
46
+ function errorCode(error) {
47
+ return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" ? error.code : undefined;
48
+ }
49
+ function validPid(pid) { return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0; }
50
+ function parseHolder(raw) {
51
+ try {
52
+ const value = JSON.parse(raw);
53
+ if (typeof value !== "object" || value === null || !("pid" in value) || !validPid(value.pid) || !("hostname" in value) || typeof value.hostname !== "string" || !("token" in value) || typeof value.token !== "string" || !("acquiredAt" in value) || typeof value.acquiredAt !== "string")
54
+ return undefined;
55
+ return { pid: value.pid, hostname: value.hostname, token: value.token, acquiredAt: value.acquiredAt };
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ /** One pure stale decision serves both I/O adapters. Invalid/foreign pids have unknown liveness,
62
+ * never false liveness; otherwise kill(0/negative, 0) would address a process group. */
63
+ export function isLockStale(required) {
64
+ return (required.ownerAlive === false || required.nowMs - required.mtimeMs > required.staleMs) && required.observedInode === required.currentInode && required.observedDevice === required.currentDevice;
65
+ }
66
+ function settings(options) {
67
+ const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS, staleMs = options.staleMs ?? DEFAULT_LOCK_STALE_AFTER_MS, pollMs = options.pollMs ?? 10;
68
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || (!Number.isSafeInteger(staleMs) && staleMs !== Infinity) || staleMs <= 0 || !Number.isSafeInteger(pollMs) || pollMs <= 0)
69
+ throw new RangeError((options.messages ?? defaultPlatformMessages).fileLockIntervalsInvalid());
70
+ const processPort = options.process ?? { pid: process.pid, isAlive: ({ pid }) => { try {
71
+ process.kill(pid, 0);
72
+ return true;
73
+ }
74
+ catch (error) {
75
+ return errorCode(error) !== "ESRCH";
76
+ } } };
77
+ const clock = options.clock ?? createSystemClock();
78
+ const monotonicMs = options.monotonicMs ?? (() => clock.nowMs());
79
+ return { timeoutMs, staleMs, pollMs, processPort, clock, monotonicMs, hostname: (options.hostname ?? nodeHostname)(), token: (options.token ?? randomUUID)() };
80
+ }
81
+ function ownership(s, options) {
82
+ return options.format === "token" ? `${s.processPort.pid}:${s.token}` : JSON.stringify({ pid: s.processPort.pid, hostname: s.hostname, token: s.token, acquiredAt: new Date(s.clock.nowMs()).toISOString() });
83
+ }
84
+ function ownerAlive(snapshot, s) {
85
+ const holder = snapshot.holder;
86
+ if (holder)
87
+ return holder.hostname === s.hostname ? s.processPort.isAlive({ pid: holder.pid }) : undefined;
88
+ // Legacy synchronous stores wrote pid:random rather than JSON. Malformed pids remain unknown.
89
+ if (!/^\d+:/.test(snapshot.raw))
90
+ return undefined;
91
+ const pid = Number(snapshot.raw.split(":", 1)[0]);
92
+ return validPid(pid) ? s.processPort.isAlive({ pid }) : undefined;
93
+ }
94
+ function unchanged(a, b) { return a.ino === b.ino && a.dev === b.dev && a.raw === b.raw && a.mtimeMs === b.mtimeMs; }
95
+ function stale(observed, current, s) {
96
+ return unchanged(observed, current) && isLockStale({ nowMs: s.clock.nowMs(), mtimeMs: observed.mtimeMs, staleMs: s.staleMs, ownerAlive: ownerAlive(current, s), observedInode: observed.ino, currentInode: current.ino, observedDevice: observed.dev, currentDevice: current.dev });
97
+ }
98
+ function checkTimeout(lockPath, holder, start, s, options) {
99
+ const waitedMs = s.monotonicMs() - start;
100
+ if (waitedMs >= s.timeoutMs) {
101
+ const message = options.timeoutMessage?.({ lockPath, waitMs: s.timeoutMs });
102
+ throw new FileLockTimeoutError({ lockPath, holder, waitedMs }, { ...(options.messages ? { messages: options.messages } : {}), ...(message !== undefined ? { message } : {}) });
103
+ }
104
+ }
105
+ function inspectSync(lockPath, filesystem) {
106
+ let descriptor;
107
+ try {
108
+ descriptor = filesystem.openSync(lockPath, "r");
109
+ }
110
+ catch (error) {
111
+ if (errorCode(error) === "ENOENT" || isContendedLockError({ code: errorCode(error), platform: process.platform }))
112
+ return undefined;
113
+ throw error;
114
+ }
115
+ try {
116
+ const info = filesystem.fstatSync(descriptor), raw = filesystem.readFileSync(descriptor, "utf8");
117
+ return { raw, holder: parseHolder(raw), mtimeMs: info.mtimeMs, ino: info.ino, dev: info.dev };
118
+ }
119
+ finally {
120
+ filesystem.closeSync(descriptor);
121
+ }
122
+ }
123
+ async function inspectAsync(lockPath, filesystem) {
124
+ let handle;
125
+ try {
126
+ handle = await filesystem.open(lockPath, "r");
127
+ }
128
+ catch (error) {
129
+ if (errorCode(error) === "ENOENT" || isContendedLockError({ code: errorCode(error), platform: process.platform }))
130
+ return undefined;
131
+ throw error;
132
+ }
133
+ try {
134
+ const info = await handle.stat(), raw = await handle.readFile("utf8");
135
+ return { raw, holder: parseHolder(raw), mtimeMs: info.mtimeMs, ino: info.ino, dev: info.dev };
136
+ }
137
+ finally {
138
+ await handle.close();
139
+ }
140
+ }
141
+ const waitCell = new Int32Array(new SharedArrayBuffer(4));
142
+ /** Synchronous read-modify-write guard. Durable stores pass their former 5s/30s/10ms values.
143
+ * Release never evicts a later owner, even when the old owner reaches finally after a steal. */
144
+ export function withFileLockSync({ lockPath, run }, options = {}) {
145
+ const filesystem = options.filesystem ?? fs, s = settings(options), start = s.monotonicMs(), raw = ownership(s, options);
146
+ if (options.createParent)
147
+ filesystem.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
148
+ let identity;
149
+ for (;;) {
150
+ let descriptor;
151
+ try {
152
+ descriptor = filesystem.openSync(lockPath, "wx", 0o600);
153
+ }
154
+ catch (error) {
155
+ if (!isContendedLockError({ code: errorCode(error), platform: process.platform }))
156
+ throw error;
157
+ }
158
+ if (descriptor !== undefined) {
159
+ try {
160
+ identity = filesystem.fstatSync(descriptor);
161
+ // An unidentifiable lock would wedge writers until stale timeout; clean a failed creation
162
+ // only while its inode still matches, rather than deleting a replacement owner.
163
+ const bytes = Buffer.from(raw);
164
+ let offset = 0;
165
+ while (offset < bytes.length) {
166
+ const written = filesystem.writeSync(descriptor, bytes, offset, bytes.length - offset);
167
+ if (written === 0)
168
+ throw new Error((options.messages ?? defaultPlatformMessages).fileLockWriteNoProgress());
169
+ offset += written;
170
+ }
171
+ }
172
+ catch (error) {
173
+ try {
174
+ const created = filesystem.fstatSync(descriptor), current = filesystem.statSync(lockPath);
175
+ if (created.ino === current.ino && created.dev === current.dev)
176
+ filesystem.rmSync(lockPath, { force: true });
177
+ }
178
+ catch { /* preserve creation failure */ }
179
+ throw error;
180
+ }
181
+ finally {
182
+ filesystem.closeSync(descriptor);
183
+ }
184
+ break;
185
+ }
186
+ const observed = inspectSync(lockPath, filesystem);
187
+ if (observed) {
188
+ const current = inspectSync(lockPath, filesystem);
189
+ if (current && stale(observed, current, s)) {
190
+ const last = filesystem.statSync(lockPath, { throwIfNoEntry: false });
191
+ if (last && last.ino === current.ino && last.dev === current.dev && last.mtimeMs === current.mtimeMs) {
192
+ try {
193
+ filesystem.rmSync(lockPath, { force: true });
194
+ options.onStaleLockRemoved?.({ holder: current.holder });
195
+ }
196
+ catch (error) {
197
+ if (errorCode(error) !== "ENOENT")
198
+ throw error;
199
+ }
200
+ }
201
+ }
202
+ }
203
+ // Every contention path, including repeated stale breaks and release races, is bounded.
204
+ checkTimeout(lockPath, observed?.holder, start, s, options);
205
+ (options.sleep ?? (({ durationMs }) => { Atomics.wait(waitCell, 0, 0, durationMs); }))({ durationMs: s.pollMs });
206
+ }
207
+ const held = () => { const current = inspectSync(lockPath, filesystem); return current && current.raw === raw && current.ino === identity.ino && current.dev === identity.dev; };
208
+ let completed = false;
209
+ try {
210
+ const value = run({ lockPath, assertHeld: (_required) => { if (!held())
211
+ throw new FileLockLostError({ lockPath }, options.messages ? { messages: options.messages } : {}); } });
212
+ completed = true;
213
+ return value;
214
+ }
215
+ finally {
216
+ try {
217
+ if (held())
218
+ filesystem.rmSync(lockPath, { force: true });
219
+ }
220
+ // A failed critical section keeps its original error; durable hosts also suppress release-only failures.
221
+ catch (error) {
222
+ if (completed && !options.suppressReleaseErrors && errorCode(error) !== "ENOENT")
223
+ throw error;
224
+ }
225
+ }
226
+ }
227
+ /** Async adapter of the same stale and ownership policy; polling yields to other writers. */
228
+ export async function withFileLock({ lockPath, run }, options = {}) {
229
+ const filesystem = options.filesystem ?? asyncFs, s = settings(options), start = s.monotonicMs(), raw = ownership(s, options);
230
+ if (options.createParent)
231
+ await filesystem.mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
232
+ let identity;
233
+ for (;;) {
234
+ let handle;
235
+ try {
236
+ handle = await filesystem.open(lockPath, "wx", 0o600);
237
+ }
238
+ catch (error) {
239
+ if (!isContendedLockError({ code: errorCode(error), platform: process.platform }))
240
+ throw error;
241
+ }
242
+ if (handle) {
243
+ try {
244
+ identity = await handle.stat();
245
+ await handle.writeFile(raw, "utf8");
246
+ }
247
+ catch (error) {
248
+ try {
249
+ const created = await handle.stat(), current = await inspectAsync(lockPath, filesystem);
250
+ if (current && created.ino === current.ino && created.dev === current.dev)
251
+ await filesystem.rm(lockPath, { force: true });
252
+ }
253
+ catch { /* preserve creation failure */ }
254
+ throw error;
255
+ }
256
+ finally {
257
+ await handle.close();
258
+ }
259
+ break;
260
+ }
261
+ const observed = await inspectAsync(lockPath, filesystem);
262
+ if (observed) {
263
+ const current = await inspectAsync(lockPath, filesystem);
264
+ if (current && stale(observed, current, s)) {
265
+ const last = await inspectAsync(lockPath, filesystem);
266
+ if (last && unchanged(current, last)) {
267
+ // Reading a descriptor pins an inode, not the pathname. Re-stat the pathname after the
268
+ // final descriptor closes, so replacement during awaited inspection cannot be stolen.
269
+ try {
270
+ const atPath = await filesystem.stat(lockPath);
271
+ if (atPath.ino === last.ino && atPath.dev === last.dev && atPath.mtimeMs === last.mtimeMs) {
272
+ await filesystem.rm(lockPath, { force: true });
273
+ options.onStaleLockRemoved?.({ holder: current.holder });
274
+ }
275
+ }
276
+ catch (error) {
277
+ if (errorCode(error) !== "ENOENT")
278
+ throw error;
279
+ }
280
+ }
281
+ }
282
+ }
283
+ checkTimeout(lockPath, observed?.holder, start, s, options);
284
+ await (options.sleep ?? (({ durationMs }) => new Promise(resolve => setTimeout(resolve, durationMs))))({ durationMs: s.pollMs });
285
+ }
286
+ const held = async () => { const current = await inspectAsync(lockPath, filesystem); return current && current.raw === raw && current.ino === identity.ino && current.dev === identity.dev; };
287
+ let completed = false;
288
+ try {
289
+ const value = await run({ lockPath, assertHeld: async (_required) => { if (!await held())
290
+ throw new FileLockLostError({ lockPath }, options.messages ? { messages: options.messages } : {}); } });
291
+ completed = true;
292
+ return value;
293
+ }
294
+ finally {
295
+ try {
296
+ if (await held())
297
+ await filesystem.rm(lockPath, { force: true });
298
+ }
299
+ // A failed critical section keeps its original error; durable hosts also suppress release-only failures.
300
+ catch (error) {
301
+ if (completed && !options.suppressReleaseErrors && errorCode(error) !== "ENOENT")
302
+ throw error;
303
+ }
304
+ }
305
+ }
306
+ //# sourceMappingURL=file-lock.js.map
@@ -0,0 +1,60 @@
1
+ import nodeFs from "node:fs";
2
+ /** Refusal of a malformed, denied, escaped, or over-limit file operation. */
3
+ export declare class FsFilePathError extends Error {
4
+ constructor({ message }: {
5
+ message: string;
6
+ });
7
+ }
8
+ export interface DenyRules {
9
+ segments: ReadonlySet<string>;
10
+ filenamePatterns: readonly RegExp[];
11
+ }
12
+ export interface GuardedReaderLimits {
13
+ maxFileBytes: number;
14
+ binarySniffBytes: number;
15
+ maxListedFiles: number;
16
+ maxWalkDepth: number;
17
+ maxWalkEntries: number;
18
+ }
19
+ export type GuardedReaderFilesystem = Pick<typeof nodeFs, "existsSync" | "lstatSync" | "readdirSync" | "realpathSync" | "statSync" | "openSync" | "readSync" | "closeSync" | "createReadStream">;
20
+ /** Native filesystem adapter; callers can replace individual operations for a virtual filesystem. */
21
+ export declare function createNodeGuardedReaderFilesystem(_required: Record<string, never>): GuardedReaderFilesystem;
22
+ export interface GuardedFileReader {
23
+ resolve(required: {
24
+ relativePath: string;
25
+ }): string;
26
+ read(required: {
27
+ relativePath: string;
28
+ }): {
29
+ content: string;
30
+ bytes: number;
31
+ };
32
+ readBytes(required: {
33
+ relativePath: string;
34
+ maxBytes: number;
35
+ }): Promise<Uint8Array>;
36
+ list(required: Record<string, never>, optional?: {
37
+ relativePath?: string;
38
+ }): {
39
+ files: string[];
40
+ truncated: boolean;
41
+ };
42
+ isDeniedFileName(required: {
43
+ fileName: string;
44
+ }): boolean;
45
+ isDeniedPathSegment(required: {
46
+ segmentName: string;
47
+ }): boolean;
48
+ }
49
+ /** Contains both requested and resolved paths, including symlink aliases and nonexistent descendants.
50
+ * Deny rules and traversal/read limits are host policy and have no implicit defaults.
51
+ */
52
+ export declare function createGuardedFileReader({ rootPath, denyRules, limits, filesystem: fs }: {
53
+ rootPath: string;
54
+ denyRules: DenyRules;
55
+ limits: GuardedReaderLimits;
56
+ filesystem: GuardedReaderFilesystem;
57
+ }, { excludedListingDirs }?: {
58
+ excludedListingDirs?: ReadonlySet<string>;
59
+ }): GuardedFileReader;
60
+ //# sourceMappingURL=guarded-reader.d.ts.map
@@ -0,0 +1,310 @@
1
+ import nodeFs from "node:fs";
2
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
+ // A distinct refusal class lets tool adapters treat rejected paths/content as actionable input
4
+ // problems instead of redacting them as an internal crash.
5
+ /** Refusal of a malformed, denied, escaped, or over-limit file operation. */
6
+ export class FsFilePathError extends Error {
7
+ constructor({ message }) { super(message); this.name = "FsFilePathError"; }
8
+ }
9
+ /** Native filesystem adapter; callers can replace individual operations for a virtual filesystem. */
10
+ export function createNodeGuardedReaderFilesystem(_required) { return nodeFs; }
11
+ /** Contains both requested and resolved paths, including symlink aliases and nonexistent descendants.
12
+ * Deny rules and traversal/read limits are host policy and have no implicit defaults.
13
+ */
14
+ export function createGuardedFileReader({ rootPath, denyRules, limits, filesystem: fs }, { excludedListingDirs = new Set(["node_modules", ".git", "dist"]) } = {}) {
15
+ if (!rootPath)
16
+ throw new TypeError("rootPath is required");
17
+ for (const value of Object.values(limits))
18
+ if (!Number.isSafeInteger(value) || value <= 0)
19
+ throw new TypeError("reader limits must be positive safe integers");
20
+ const { existsSync, lstatSync, readdirSync, realpathSync, statSync } = fs;
21
+ const MAX_FS_FILE_BYTES = limits.maxFileBytes, BINARY_SNIFF_BYTES = limits.binarySniffBytes;
22
+ const MAX_LISTED_FILES = limits.maxListedFiles, MAX_WALK_DEPTH = limits.maxWalkDepth, MAX_WALK_ENTRIES = limits.maxWalkEntries;
23
+ // Listing exclusions reduce noise; they never grant or deny a known file read. Security comes
24
+ // from denyRules, applied to both the caller spelling and the effective target.
25
+ const EXCLUDED_LISTING_DIR_NAMES = excludedListingDirs;
26
+ function isDeniedFsFileName(fileName) {
27
+ const folded = foldFsNameForDenylist(fileName);
28
+ return denyRules.filenamePatterns.some((pattern) => new RegExp(pattern).test(fileName) || new RegExp(pattern).test(folded));
29
+ }
30
+ function isDeniedFsPathSegment(segmentName) {
31
+ return denyRules.segments.has(segmentName.toLowerCase()) || denyRules.segments.has(foldFsNameForDenylist(segmentName));
32
+ }
33
+ // Default case-insensitive APFS can open LONG S/KELVIN SIGN aliases of denied names that plain
34
+ // toLowerCase or non-Unicode /i misses. Windows also ignores trailing dots/spaces and opens
35
+ // name::$DATA as name. Compare raw AND folded names: folding may add denials, never remove them,
36
+ // and must not change the path actually opened.
37
+ function foldFsNameForDenylist(name) {
38
+ const withoutStream = name.replace(/:[^]*$/, "");
39
+ const withoutTrailing = withoutStream.replace(/[. ]+$/, "");
40
+ return withoutTrailing.toLowerCase().toUpperCase().toLowerCase();
41
+ }
42
+ // Split both separator spellings even on a host whose native separator is '/', so deny checks
43
+ // remain cross-platform. The guard and reader must resolve the same spelling consistently.
44
+ function normalizeFsRelativePath(relativePath) {
45
+ const segments = [];
46
+ for (const segment of relativePath.split(/[\\/]/)) {
47
+ if (segment === "" || segment === ".")
48
+ continue;
49
+ if (segment === "..") {
50
+ segments.pop();
51
+ continue;
52
+ }
53
+ segments.push(segment);
54
+ }
55
+ return segments.join("/");
56
+ }
57
+ // A string prefix admits siblings whose names extend the root and ignores traversal. Resolve
58
+ // first, then compare path.relative components; symlink containment needs its own check below.
59
+ function isWithin(base, target) {
60
+ if (base === target)
61
+ return true;
62
+ const rel = relative(base, target);
63
+ return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
64
+ }
65
+ // Probe the deepest existing ancestor: a nonexistent descendant can still sit beneath a
66
+ // symlink that escapes the root, even when its lexical path passed containment.
67
+ // Bound ancestor steps to four times the listing depth, plus the endpoint check, so deeply
68
+ // nested missing paths cannot cause unbounded synchronous probes. Unknown containment refuses.
69
+ function assertNoSymlinkEscape(base, path, relativePathForError) {
70
+ let probe = path;
71
+ for (let i = 0; i < MAX_WALK_DEPTH * 4 && !existsSync(probe); i += 1) {
72
+ const parent = dirname(probe);
73
+ if (parent === probe)
74
+ break;
75
+ probe = parent;
76
+ }
77
+ if (!existsSync(probe))
78
+ throw new FsFilePathError({ message: `path '${relativePathForError}' containment could not be established within the ancestor traversal limit` });
79
+ const realProbe = realpathSync(probe);
80
+ if (!isWithin(base, realProbe)) {
81
+ throw new FsFilePathError({ message: `path '${relativePathForError}' resolves outside the allowed root through a symbolic link` });
82
+ }
83
+ }
84
+ function resolveFsFilePath(required) {
85
+ const { rootPath, relativePath } = required;
86
+ assertWellFormedRelativePath(relativePath);
87
+ assertRequestedPathNotDenied(relativePath);
88
+ // Canonicalize a symlinked root too, or the ancestor's realpath would compare to a different
89
+ // representation and refuse legitimate reads through a linked package/root.
90
+ const base = existsSync(rootPath) ? realpathSync(rootPath) : resolve(rootPath);
91
+ const target = resolve(base, relativePath);
92
+ if (!isWithin(base, target)) {
93
+ throw new FsFilePathError({ message: `path '${relativePath}' resolves outside the allowed root` });
94
+ }
95
+ assertNoSymlinkEscape(base, target, relativePath);
96
+ assertEffectiveTargetNotDenied(base, target, relativePath);
97
+ return target;
98
+ }
99
+ // Isolate initial shape refusals so resolve reads as the ordered sequence of containment and
100
+ // deny checks, rather than burying that boundary in one long guard block.
101
+ function assertWellFormedRelativePath(relativePath) {
102
+ if (relativePath.length === 0) {
103
+ throw new FsFilePathError({ message: "path is required" });
104
+ }
105
+ if (relativePath.includes("\0")) {
106
+ throw new FsFilePathError({ message: "path must not contain a NUL byte" });
107
+ }
108
+ if (isAbsolute(relativePath)) {
109
+ throw new FsFilePathError({ message: `path '${relativePath}' must be relative to the root, not absolute` });
110
+ }
111
+ }
112
+ // Refuse denied spellings before filesystem access, regardless of whether they exist. The
113
+ // effective-target check is separate because a harmless alias can resolve to a denied file.
114
+ function assertRequestedPathNotDenied(relativePath) {
115
+ const normalized = normalizeFsRelativePath(relativePath);
116
+ const segments = normalized.length === 0 ? [] : normalized.split("/");
117
+ for (const segment of segments) {
118
+ if (isDeniedFsPathSegment(segment)) {
119
+ throw new FsFilePathError({ message: `path '${relativePath}' contains a denied path segment ('${segment}') and cannot be accessed` });
120
+ }
121
+ }
122
+ const leafName = segments.at(-1) ?? "";
123
+ if (leafName.length > 0 && isDeniedFsFileName(leafName)) {
124
+ throw new FsFilePathError({ message: `path '${relativePath}' matches a denied filename pattern and cannot be accessed` });
125
+ }
126
+ }
127
+ // public.txt -> .env stays inside the root and passes lexical checks; Node follows it on read.
128
+ // Deny the real target as well. existsSync excludes broken/circular links before realpath, and
129
+ // the subsequent stat translates their failure into this reader's own refusal class.
130
+ function assertEffectiveTargetNotDenied(base, target, relativePath) {
131
+ if (!existsSync(target))
132
+ return;
133
+ const effectiveSegments = relative(base, realpathSync(target)).split(sep);
134
+ for (const segment of effectiveSegments) {
135
+ if (isDeniedFsPathSegment(segment)) {
136
+ throw new FsFilePathError({ message: `path '${relativePath}' resolves to a denied path segment ('${segment}') and cannot be accessed` });
137
+ }
138
+ }
139
+ // A target equal to base yields [""] after split, so check leaf length, not array length.
140
+ const effectiveLeaf = effectiveSegments.at(-1) ?? "";
141
+ if (effectiveLeaf.length > 0 && isDeniedFsFileName(effectiveLeaf)) {
142
+ throw new FsFilePathError({ message: `path '${relativePath}' resolves to a denied filename pattern and cannot be accessed` });
143
+ }
144
+ }
145
+ function visitFsDirEntry(dir, name, depth, base, found, state) {
146
+ const full = resolve(dir, name);
147
+ // lstat examines the link itself: stat would follow circular links and throw ELOOP before
148
+ // the skip. Never enumerate/report links or descend denied directories, so listings do not
149
+ // advertise secrets or anything outside the root. Ordinary excluded directories are noise.
150
+ const stat = lstatSync(full, { throwIfNoEntry: false });
151
+ if (!stat)
152
+ return;
153
+ if (stat.isSymbolicLink())
154
+ return;
155
+ if (stat.isDirectory()) {
156
+ if (isDeniedFsPathSegment(name) || EXCLUDED_LISTING_DIR_NAMES.has(name))
157
+ return;
158
+ walkFsDir(full, depth + 1, base, found, state);
159
+ return;
160
+ }
161
+ if (!stat.isFile())
162
+ return;
163
+ if (isDeniedFsFileName(name))
164
+ return;
165
+ found.push(relative(base, full).split(sep).join("/"));
166
+ }
167
+ // Results, entries, and depth are independent limits; keep the decision shared by entry and
168
+ // loop checks so adding one limit cannot leave an unbounded arm.
169
+ function walkBoundReached(found, state, depth) {
170
+ return depth > MAX_WALK_DEPTH || found.length >= MAX_LISTED_FILES || state.remaining <= 0;
171
+ }
172
+ function walkFsDir(dir, depth, base, found, state) {
173
+ if (walkBoundReached(found, state, depth)) {
174
+ state.truncated = true;
175
+ return;
176
+ }
177
+ for (const name of readdirSync(dir)) {
178
+ // Recheck per entry: this loop spends traversal budget and adds results. Remaining
179
+ // entries mean a bound must set truncated rather than present a short list as complete.
180
+ if (walkBoundReached(found, state, depth)) {
181
+ state.truncated = true;
182
+ return;
183
+ }
184
+ state.remaining -= 1;
185
+ visitFsDirEntry(dir, name, depth, base, found, state);
186
+ }
187
+ }
188
+ function listFsFiles(required) {
189
+ const { rootPath } = required;
190
+ const relativePath = required.relativePath ?? "";
191
+ const startDir = relativePath.length === 0 ? resolve(rootPath) : resolveFsFilePath({ rootPath, relativePath });
192
+ // Freshly provisioned roots/subdirectories may not exist yet; an empty listing is valid.
193
+ if (!existsSync(startDir))
194
+ return { files: [], truncated: false };
195
+ const startStat = statOrFsPathError(startDir, relativePath || ".");
196
+ if (!startStat)
197
+ return { files: [], truncated: false };
198
+ if (!startStat.isDirectory()) {
199
+ throw new FsFilePathError({ message: `path '${relativePath || "."}' is not a directory` });
200
+ }
201
+ const base = realpathSync(startDir);
202
+ const found = [];
203
+ const state = { remaining: MAX_WALK_ENTRIES, truncated: false };
204
+ walkFsDir(base, 0, base, found, state);
205
+ // Paths are relative to the requested walk base, so a subdirectory listing can be consumed
206
+ // without stripping that subdirectory's prefix a second time.
207
+ return { files: found.sort(), truncated: state.truncated };
208
+ }
209
+ // Circular-link ELOOP and other filesystem exceptions must become the reader's refusal class,
210
+ // not escape raw while an ordinary missing path is represented by undefined.
211
+ function statOrFsPathError(target, relativePath) {
212
+ try {
213
+ return statSync(target, { throwIfNoEntry: false });
214
+ }
215
+ catch (err) {
216
+ throw new FsFilePathError({ message: `path '${relativePath}' could not be read: ${err instanceof Error ? err.message : String(err)}` });
217
+ }
218
+ }
219
+ // A bounded leading-NUL heuristic keeps binary databases/images/archives out of inline UTF-8
220
+ // model input without making type sniffing proportional to the full file size.
221
+ function looksBinary(buffer) {
222
+ const sniffLength = Math.min(buffer.length, BINARY_SNIFF_BYTES);
223
+ for (let i = 0; i < sniffLength; i += 1) {
224
+ if (buffer[i] === 0)
225
+ return true;
226
+ }
227
+ return false;
228
+ }
229
+ function readFsFile(required) {
230
+ const target = resolveFsFilePath(required);
231
+ const stat = statOrFsPathError(target, required.relativePath);
232
+ if (!stat)
233
+ throw new FsFilePathError({ message: `file '${required.relativePath}' does not exist` });
234
+ if (!stat.isFile())
235
+ throw new FsFilePathError({ message: `path '${required.relativePath}' is not a regular file` });
236
+ if (stat.size > MAX_FS_FILE_BYTES) {
237
+ throw new FsFilePathError({ message: `file '${required.relativePath}' exceeds the ${MAX_FS_FILE_BYTES}-byte readable limit` });
238
+ }
239
+ const fd = fs.openSync(target, "r");
240
+ let buffer;
241
+ try {
242
+ const bounded = Buffer.alloc(MAX_FS_FILE_BYTES + 1);
243
+ let count = 0;
244
+ while (count < bounded.length) {
245
+ const read = fs.readSync(fd, bounded, count, bounded.length - count, null);
246
+ if (read === 0)
247
+ break;
248
+ count += read;
249
+ }
250
+ if (count > MAX_FS_FILE_BYTES)
251
+ throw new FsFilePathError({ message: `file '${required.relativePath}' exceeds the ${MAX_FS_FILE_BYTES}-byte readable limit` });
252
+ buffer = bounded.subarray(0, count);
253
+ }
254
+ finally {
255
+ fs.closeSync(fd);
256
+ }
257
+ if (looksBinary(buffer)) {
258
+ throw new FsFilePathError({ message: `file '${required.relativePath}' looks like a binary file and cannot be read as text` });
259
+ }
260
+ return { content: buffer.toString("utf8"), bytes: buffer.byteLength };
261
+ }
262
+ // A stat-only size check can be bypassed by a growing file: bound the stream independently and
263
+ // reject rather than return a clipped image/video. Destroy the stream on success or failure.
264
+ async function openFsFileForRead(required) {
265
+ const { relativePath, maxBytes } = required;
266
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
267
+ throw new FsFilePathError({ message: "maxBytes must be a positive safe integer" });
268
+ }
269
+ const target = resolveFsFilePath(required);
270
+ const stat = statOrFsPathError(target, relativePath);
271
+ if (!stat)
272
+ throw new FsFilePathError({ message: `file '${relativePath}' does not exist` });
273
+ if (!stat.isFile())
274
+ throw new FsFilePathError({ message: `path '${relativePath}' is not a regular file` });
275
+ if (stat.size > maxBytes) {
276
+ throw new FsFilePathError({ message: `file '${relativePath}' is ${stat.size} bytes and exceeds the ${maxBytes}-byte import limit` });
277
+ }
278
+ const stream = fs.createReadStream(target, { highWaterMark: Math.min(64 * 1024, maxBytes + 1) });
279
+ const chunks = [];
280
+ let totalBytes = 0;
281
+ try {
282
+ for await (const chunk of stream) {
283
+ const bytes = chunk;
284
+ totalBytes += bytes.byteLength;
285
+ if (totalBytes > maxBytes) {
286
+ throw new FsFilePathError({ message: `file '${relativePath}' is at least ${totalBytes} bytes and exceeds the ${maxBytes}-byte import limit` });
287
+ }
288
+ chunks.push(bytes);
289
+ }
290
+ return Buffer.concat(chunks, totalBytes);
291
+ }
292
+ catch (error) {
293
+ if (error instanceof FsFilePathError)
294
+ throw error;
295
+ throw new FsFilePathError({ message: `path '${relativePath}' could not be read: ${error instanceof Error ? error.message : String(error)}` });
296
+ }
297
+ finally {
298
+ stream.destroy();
299
+ }
300
+ }
301
+ return {
302
+ resolve: ({ relativePath }) => resolveFsFilePath({ rootPath, relativePath }),
303
+ read: ({ relativePath }) => readFsFile({ rootPath, relativePath }),
304
+ readBytes: ({ relativePath, maxBytes }) => openFsFileForRead({ rootPath, relativePath, maxBytes }),
305
+ list: (_required, optional = {}) => listFsFiles({ rootPath, ...optional }),
306
+ isDeniedFileName: ({ fileName }) => isDeniedFsFileName(fileName),
307
+ isDeniedPathSegment: ({ segmentName }) => isDeniedFsPathSegment(segmentName),
308
+ };
309
+ }
310
+ //# sourceMappingURL=guarded-reader.js.map