Skip to content

Commit b9d22f8

Browse files
authored
Configurable plugin limits (#106)
* feat: make hardcoded plugin limits user-configurable fs-read: - maxReadChunkKb: per-call read limit (default 1024 KB, max 10240 KB) - maxListResults: listDir result cap (default 1000, max 50000) fs-write: - maxWriteChunkKb: per-call write limit (default 2048 KB, max 10240 KB) fetch: - maxRedirects: HTTP redirect hops (default 5, max 20) - maxJsonResponseBytes: fetchJSON size cap (default 1 MB, max 10 MB) - maxTextResponseBytes: fetchText size cap (default 2 MB, max 10 MB) All new fields follow the existing config schema pattern with sensible defaults matching previous hardcoded values, so behaviour is unchanged unless explicitly overridden. Hard ceilings remain as safety guards. Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com> * feat: remove artificial ceilings from plugin config schemas All schema 'maximum' fields removed from plugin configs. Users can now set any value they want — the OS/hardware is the only limit, not arbitrary numbers we picked. Defaults remain sensible and safe. - fs-read: no ceiling on maxFileSizeKb, maxReadChunkKb, maxListResults - fs-write: no ceiling on maxWriteSizeKb, maxWriteChunkKb, maxEntries - fetch: no ceiling on any numeric config field (response sizes, rate limits, timeouts, cache sizes, session budgets — all uncapped) Removed unused MAX_SIZE_LIMIT_KB, MAX_READ_CHUNK_KB, MAX_LIST_RESULTS, MAX_WRITE_CHUNK_KB, MAX_ENTRIES_LIMIT, MAX_REDIRECTS constants. Updated plugin.json hints and PLUGINS.md docs to reflect configurable (not fixed) per-call limits. Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com> * fix: address PR #106 review feedback Bug fixes: - fs-read/fs-write: pass Number.MAX_SAFE_INTEGER as ceiling to safeNumericConfig — the shared helper defaults to 10240 KB which silently clamped maxWriteSizeKb (default 20480) and other values back to 10 MB, defeating the 'no artificial ceilings' change - fetch: use Buffer.byteLength(body, 'utf8') instead of body.length for maxJsonResponseBytes and maxTextResponseBytes guards — body.length counts UTF-16 code units which undercounts for non-ASCII content New tests (14 tests added): - fs-read: maxReadChunkKb enforcement, maxListResults truncation, maxFileSizeKb above old 10 MB ceiling - fs-write: maxWriteChunkKb enforcement for text and binary writes, maxWriteSizeKb above old 50 MB ceiling - fetch: config acceptance for maxRedirects, maxJsonResponseBytes, maxTextResponseBytes, maxDataReceivedKb, and uncapped rate limits Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com> --------- Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
1 parent 580ce1a commit b9d22f8

9 files changed

Lines changed: 322 additions & 110 deletions

File tree

docs/PLUGINS.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,6 @@ export const SCHEMA = {
548548
description: "Maximum file size in KB",
549549
default: 1024,
550550
minimum: 1,
551-
maximum: 10240,
552551
},
553552
} satisfies ConfigSchema;
554553

plugins/fetch/index.ts

Lines changed: 87 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -95,76 +95,66 @@ export const SCHEMA = {
9595
},
9696
connectTimeoutMs: {
9797
type: "number" as const,
98-
description: "TCP+TLS connect timeout in milliseconds (max 10000)",
98+
description: "TCP+TLS connect timeout in milliseconds",
9999
default: 5000,
100100
minimum: 1000,
101-
maximum: 10000,
102101
},
103102
readTimeoutMs: {
104103
type: "number" as const,
105-
description: "Read timeout in milliseconds (max 30000)",
104+
description: "Read timeout in milliseconds",
106105
default: 10000,
107106
minimum: 1000,
108-
maximum: 30000,
109107
},
110108
maxResponseSizeKb: {
111109
type: "number" as const,
112110
description:
113-
"Maximum total response body size in KB (max 8192). Responses larger than this are rejected.",
111+
"Maximum total response body size in KB. Responses larger than this are rejected.",
114112
default: 1024,
115113
minimum: 1,
116-
maximum: 8192,
117114
},
118115
readSizeKb: {
119116
type: "number" as const,
120117
description:
121-
"Maximum body size returned per read() call in KB (max 256). Must be smaller than the sandbox output buffer.",
118+
"Maximum body size returned per read() call in KB. Must be smaller than the sandbox output buffer.",
122119
default: 48,
123120
minimum: 8,
124-
maximum: 256,
125121
},
126122
responseCacheTtlSeconds: {
127123
type: "number" as const,
128124
description:
129-
"How long response bodies stay cached on the host before expiring (seconds, max 600)",
125+
"How long response bodies stay cached on the host before expiring (seconds)",
130126
default: 300,
131127
minimum: 30,
132-
maximum: 600,
133128
},
134129
maxRequestBodySizeKb: {
135130
type: "number" as const,
136-
description: "Maximum POST request body size in KB (max 64)",
131+
description: "Maximum POST request body size in KB",
137132
default: 4,
138133
minimum: 1,
139-
maximum: 64,
140134
},
141135
maxRequestsPerMinute: {
142136
type: "number" as const,
143137
description: "Maximum fetch calls per minute (sliding window)",
144138
default: 30,
145139
minimum: 1,
146-
maximum: 60,
147140
},
148141
maxRequestsPerHour: {
149142
type: "number" as const,
150143
description: "Maximum fetch calls per hour (session-scoped)",
151144
default: 100,
152145
minimum: 1,
153-
maximum: 500,
154146
},
155147
maxDomainsPerSession: {
156148
type: "number" as const,
157149
description: "Maximum unique domains per session",
158150
default: 5,
159151
minimum: 1,
160-
maximum: 20,
161152
},
162153
maxDataReceivedKb: {
163154
type: "number" as const,
164155
description: "Maximum total response data per session in KB",
165156
default: 2048,
166157
minimum: 1,
167-
maximum: 16384,
168158
},
169159
returnXRequestId: {
170160
type: "boolean" as const,
@@ -175,18 +165,16 @@ export const SCHEMA = {
175165
conditionalCacheMaxEntries: {
176166
type: "number" as const,
177167
description:
178-
"Maximum number of URLs cached for conditional requests (ETag/Last-Modified). 0 effectively disables caching (min 1).",
168+
"Maximum number of URLs cached for conditional requests (ETag/Last-Modified).",
179169
default: 20,
180170
minimum: 1,
181-
maximum: 100,
182171
},
183172
conditionalCacheTtlSeconds: {
184173
type: "number" as const,
185174
description:
186175
"How long conditional-cache entries remain valid (seconds). After this, the next GET sends a normal request without conditional headers.",
187176
default: 600,
188177
minimum: 60,
189-
maximum: 3600,
190178
},
191179
autoRetryOn429: {
192180
type: "boolean" as const,
@@ -200,31 +188,48 @@ export const SCHEMA = {
200188
"Maximum seconds to wait for a single 429 retry. If server asks for longer, returns error instead of waiting.",
201189
default: 30,
202190
minimum: 1,
203-
maximum: 120,
204191
},
205192
autoRetryMaxAttempts: {
206193
type: "number" as const,
207194
description:
208195
"Maximum number of retry attempts on 429 before giving up and returning the error.",
209196
default: 3,
210197
minimum: 1,
211-
maximum: 10,
212198
},
213199
maxParallelFetches: {
214200
type: "number" as const,
215201
description:
216202
"Maximum concurrent requests for batch operations like fetchBinaryBatch. Higher values speed up bulk downloads but may trigger server rate limits. Default 1 (serial).",
217203
default: 1,
218204
minimum: 1,
219-
maximum: 10,
220205
},
221206
diskCacheMaxMb: {
222207
type: "number" as const,
223208
description:
224209
"Maximum disk cache size in MB for anonymous HTTP responses. Cached in $HOME/.hyperagent/fetch-cache with LFU eviction. Set to 0 to disable.",
225210
default: 100,
226211
minimum: 0,
227-
maximum: 1000,
212+
},
213+
maxRedirects: {
214+
type: "number" as const,
215+
description:
216+
"Maximum number of HTTP redirects to follow. Each hop is re-validated against the domain allowlist and SSRF checks.",
217+
default: 5,
218+
minimum: 0,
219+
},
220+
maxJsonResponseBytes: {
221+
type: "number" as const,
222+
description:
223+
"Maximum response size in bytes for fetchJSON convenience method. Larger responses should use get() + read() streaming.",
224+
default: 1048576,
225+
minimum: 1024,
226+
},
227+
maxTextResponseBytes: {
228+
type: "number" as const,
229+
description:
230+
"Maximum response size in bytes for fetchText convenience method. Larger responses should use get() + read() streaming.",
231+
default: 2097152,
232+
minimum: 1024,
228233
},
229234
} satisfies ConfigSchema;
230235

@@ -462,6 +467,7 @@ interface SecureFetchOptions {
462467
returnXRequestId: boolean;
463468
exactDomains: Set<string>;
464469
wildcardDomains: string[];
470+
maxRedirects: number;
465471
signal?: AbortSignal;
466472
}
467473

@@ -492,10 +498,6 @@ interface SecureFetchSingleOptions extends Omit<
492498
* A blocked domain and a successful fetch both take ≥ this long. */
493499
const MIN_RESPONSE_DELAY_MS = 200;
494500

495-
/** Maximum number of HTTP redirects to follow. Each hop is
496-
* re-validated against the domain allowlist and SSRF checks. */
497-
const MAX_REDIRECTS = 5;
498-
499501
/** HTTP status codes that trigger redirect following. */
500502
const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
501503

@@ -2453,7 +2455,7 @@ function validateRedirectTarget(
24532455
/**
24542456
* Perform a secure HTTPS request with redirect following.
24552457
*
2456-
* Wraps secureFetchSingle in a redirect loop (up to MAX_REDIRECTS hops).
2458+
* Wraps secureFetchSingle in a redirect loop (up to opts.maxRedirects hops).
24572459
* Each redirect target is fully re-validated:
24582460
* - HTTPS only (no protocol downgrade)
24592461
* - Domain must be in the operator's allowlist
@@ -2487,7 +2489,7 @@ async function secureFetch(
24872489
let currentBody = opts.body;
24882490
const visited = new Set();
24892491

2490-
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
2492+
for (let hop = 0; hop <= opts.maxRedirects; hop++) {
24912493
const urlKey = currentUrl.href;
24922494

24932495
// Redirect loop detection
@@ -2544,7 +2546,9 @@ async function secureFetch(
25442546
}
25452547

25462548
// Exhausted redirect budget
2547-
return { error: `fetch blocked: too many redirects (max ${MAX_REDIRECTS})` };
2549+
return {
2550+
error: `fetch blocked: too many redirects (max ${opts.maxRedirects})`,
2551+
};
25482552
}
25492553

25502554
// ── Utility ──────────────────────────────────────────────────────────
@@ -2837,27 +2841,35 @@ export function createHostFunctions(config?: FetchConfig): FetchHostFunctions {
28372841
// Enforce manifest-declared minimums as the floor parameter (4th arg).
28382842
// Previously floor defaulted to 1, so e.g. connectTimeoutMs=1 was silently
28392843
// accepted despite the manifest declaring minimum: 1000 (audit finding F-08).
2844+
// No artificial ceilings — the user decides what's appropriate for their
2845+
// hardware and use case. Number.MAX_SAFE_INTEGER means "no ceiling".
2846+
const NO_CEIL = Number.MAX_SAFE_INTEGER;
28402847
const connectTimeoutMs = safeNumericConfig(
28412848
cfg.connectTimeoutMs,
28422849
5000,
2843-
10_000,
2850+
NO_CEIL,
28442851
1000,
28452852
);
28462853
const readTimeoutMs = safeNumericConfig(
28472854
cfg.readTimeoutMs,
28482855
10_000,
2849-
30_000,
2856+
NO_CEIL,
28502857
1000,
28512858
);
28522859
const maxResponseBytes =
2853-
safeNumericConfig(cfg.maxResponseSizeKb, 256, 8192) * 1024;
2854-
const readSizeBytes = safeNumericConfig(cfg.readSizeKb, 48, 256, 8) * 1024;
2860+
safeNumericConfig(cfg.maxResponseSizeKb, 1024, NO_CEIL) * 1024;
2861+
const readSizeBytes =
2862+
safeNumericConfig(cfg.readSizeKb, 48, NO_CEIL, 8) * 1024;
28552863
const responseCacheTtlMs =
2856-
safeNumericConfig(cfg.responseCacheTtlSeconds, 300, 600, 30) * 1000;
2864+
safeNumericConfig(cfg.responseCacheTtlSeconds, 300, NO_CEIL, 30) * 1000;
28572865
const maxRequestBodyBytes =
2858-
safeNumericConfig(cfg.maxRequestBodySizeKb, 4, 64) * 1024;
2859-
const maxPerMinuteRaw = safeNumericConfig(cfg.maxRequestsPerMinute, 30, 60);
2860-
const maxPerHour = safeNumericConfig(cfg.maxRequestsPerHour, 100, 500);
2866+
safeNumericConfig(cfg.maxRequestBodySizeKb, 4, NO_CEIL) * 1024;
2867+
const maxPerMinuteRaw = safeNumericConfig(
2868+
cfg.maxRequestsPerMinute,
2869+
30,
2870+
NO_CEIL,
2871+
);
2872+
const maxPerHour = safeNumericConfig(cfg.maxRequestsPerHour, 100, NO_CEIL);
28612873
// Clamp per-minute to never exceed per-hour — an operator setting
28622874
// 60/minute with 1/hour makes no sense and defeats the hourly cap.
28632875
const maxPerMinute = Math.min(maxPerMinuteRaw, maxPerHour);
@@ -2866,39 +2878,58 @@ export function createHostFunctions(config?: FetchConfig): FetchHostFunctions {
28662878
`[fetch] maxRequestsPerMinute (${maxPerMinuteRaw}) exceeds maxRequestsPerHour (${maxPerHour}) — clamped to ${maxPerMinute}`,
28672879
);
28682880
}
2869-
const maxDomains = safeNumericConfig(cfg.maxDomainsPerSession, 5, 20);
2881+
const maxDomains = safeNumericConfig(cfg.maxDomainsPerSession, 5, NO_CEIL);
28702882
const maxDataReceivedBytes =
2871-
safeNumericConfig(cfg.maxDataReceivedKb, 512, 16384) * 1024;
2883+
safeNumericConfig(cfg.maxDataReceivedKb, 2048, NO_CEIL) * 1024;
28722884
const returnXRequestId = !!cfg.returnXRequestId;
28732885
const conditionalCacheMax = safeNumericConfig(
28742886
cfg.conditionalCacheMaxEntries,
28752887
20,
2876-
100,
2888+
NO_CEIL,
28772889
);
28782890
const conditionalCacheTtlMs =
2879-
safeNumericConfig(cfg.conditionalCacheTtlSeconds, 600, 3600, 60) * 1000;
2891+
safeNumericConfig(cfg.conditionalCacheTtlSeconds, 600, NO_CEIL, 60) * 1000;
28802892

28812893
// Auto-retry on 429 configuration
28822894
const autoRetryOn429 = !!cfg.autoRetryOn429;
28832895
const autoRetryMaxWaitSeconds = safeNumericConfig(
28842896
cfg.autoRetryMaxWaitSeconds,
28852897
30,
2886-
120,
2898+
NO_CEIL,
28872899
);
28882900
const autoRetryMaxAttempts = safeNumericConfig(
28892901
cfg.autoRetryMaxAttempts,
28902902
3,
2891-
10,
2903+
NO_CEIL,
28922904
);
28932905

28942906
// Parallel fetch configuration — controls how many requests can be in flight
28952907
// simultaneously. Default 1 for backwards compatibility (serial).
28962908
// Higher values speed up batch downloads but may trigger server rate limits.
2897-
const maxParallelFetches = safeNumericConfig(cfg.maxParallelFetches, 1, 10);
2909+
const maxParallelFetches = safeNumericConfig(
2910+
cfg.maxParallelFetches,
2911+
1,
2912+
NO_CEIL,
2913+
);
2914+
2915+
// Redirect, JSON, and text response size limits — user-configurable.
2916+
const maxRedirects = safeNumericConfig(cfg.maxRedirects, 5, NO_CEIL, 0);
2917+
const maxJsonResponseBytes = safeNumericConfig(
2918+
cfg.maxJsonResponseBytes,
2919+
1024 * 1024,
2920+
NO_CEIL,
2921+
1024,
2922+
);
2923+
const maxTextResponseBytes = safeNumericConfig(
2924+
cfg.maxTextResponseBytes,
2925+
2 * 1024 * 1024,
2926+
NO_CEIL,
2927+
1024,
2928+
);
28982929

28992930
// Disk cache configuration — persistent LFU cache in $HOME/.hyperagent/fetch-cache
29002931
const diskCacheMaxBytes =
2901-
safeNumericConfig(cfg.diskCacheMaxMb, 100, 1000, 0) * 1024 * 1024;
2932+
safeNumericConfig(cfg.diskCacheMaxMb, 100, NO_CEIL, 0) * 1024 * 1024;
29022933

29032934
// Build allowed header names set (lowercased)
29042935
const rawAllowedHeaders = Array.isArray(cfg.allowedRequestHeaders)
@@ -3177,6 +3208,7 @@ export function createHostFunctions(config?: FetchConfig): FetchHostFunctions {
31773208
returnXRequestId,
31783209
exactDomains,
31793210
wildcardDomains,
3211+
maxRedirects,
31803212
signal,
31813213
}),
31823214
safetyTimeout,
@@ -3478,12 +3510,13 @@ export function createHostFunctions(config?: FetchConfig): FetchHostFunctions {
34783510
const body = chunks.join("");
34793511

34803512
// Guard against oversized responses blowing through heap limits.
3481-
// 1MB is reasonable for JSON APIs; larger responses should stream.
3482-
const MAX_JSON_BYTES = 1024 * 1024;
3483-
if (body.length > MAX_JSON_BYTES) {
3513+
// Use Buffer.byteLength for accurate UTF-8 byte count (body.length
3514+
// counts UTF-16 code units which undercounts for non-ASCII content).
3515+
const jsonBodyBytes = Buffer.byteLength(body, "utf8");
3516+
if (jsonBodyBytes > maxJsonResponseBytes) {
34843517
throw new Error(
34853518
`fetchJSON: response too large ` +
3486-
`(${body.length} bytes, max ${MAX_JSON_BYTES}). ` +
3519+
`(${jsonBodyBytes} bytes, max ${maxJsonResponseBytes}). ` +
34873520
`Use get() + read() loop to stream large responses instead.`,
34883521
);
34893522
}
@@ -3543,12 +3576,12 @@ export function createHostFunctions(config?: FetchConfig): FetchHostFunctions {
35433576
const body = chunks.join("");
35443577

35453578
// Guard against oversized responses blowing through heap limits.
3546-
// 2MB is reasonable for text content like HTML pages.
3547-
const MAX_TEXT_BYTES = 2 * 1024 * 1024;
3548-
if (body.length > MAX_TEXT_BYTES) {
3579+
// Use Buffer.byteLength for accurate UTF-8 byte count.
3580+
const textBodyBytes = Buffer.byteLength(body, "utf8");
3581+
if (textBodyBytes > maxTextResponseBytes) {
35493582
throw new Error(
35503583
`fetchText: response too large ` +
3551-
`(${body.length} bytes, max ${MAX_TEXT_BYTES}). ` +
3584+
`(${textBodyBytes} bytes, max ${maxTextResponseBytes}). ` +
35523585
`Use get() + read() loop to stream large responses instead.`,
35533586
);
35543587
}

0 commit comments

Comments
 (0)