graceful-boundaries 1.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.
- package/CONFORMANCE.md +78 -0
- package/LICENSE +36 -0
- package/LICENSE-SPEC +12 -0
- package/README.md +262 -0
- package/bin/cli.js +23 -0
- package/evals/check.js +1167 -0
- package/package.json +58 -0
- package/schema/limits.schema.json +128 -0
- package/schema/refusal-429.schema.json +8 -0
- package/schema/refusal.schema.json +100 -0
- package/spec.md +1013 -0
package/evals/check.js
ADDED
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Graceful Boundaries conformance checker.
|
|
5
|
+
*
|
|
6
|
+
* Tests a live service against the three conformance levels:
|
|
7
|
+
* N/A: Not Applicable — no agentic interaction surface
|
|
8
|
+
* Level 0: Non-Conformant — limits exist but are not described
|
|
9
|
+
* Level 1: Structured Refusal — all non-success responses include core fields; 429s include limit fields
|
|
10
|
+
* Level 2: Discoverable — a limits discovery endpoint exists
|
|
11
|
+
* Level 3: Constructive — refusal responses include guidance fields
|
|
12
|
+
* Level 4: Proactive — successful responses include proactive limit headers
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* node evals/check.js https://your-service.com
|
|
16
|
+
* node evals/check.js https://your-service.com --limits-path /api/limits
|
|
17
|
+
* node evals/check.js https://your-service.com --json
|
|
18
|
+
* node evals/check.js https://your-service.com --check-cloaking
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const REQUIRED_REFUSAL_FIELDS = ["error", "detail", "limit", "retryAfterSeconds", "why"];
|
|
22
|
+
const CONSTRUCTIVE_FIELDS = ["cached", "cachedResultUrl", "alternativeEndpoint", "upgradeUrl", "humanUrl"];
|
|
23
|
+
const DEFAULT_LIMITS_PATHS = ["/api/limits", "/.well-known/limits"];
|
|
24
|
+
const MACHINE_ACTIONABLE_URL_FIELDS = ["cachedResultUrl", "alternativeEndpoint", "scanUrl"];
|
|
25
|
+
const OPTIONAL_LIMIT_STRING_FIELDS = ["limitId", "limitType", "scope", "costMetric"];
|
|
26
|
+
const OPTIONAL_LIMIT_NUMBER_FIELDS = [
|
|
27
|
+
"maxInputBytes",
|
|
28
|
+
"maxInputTokens",
|
|
29
|
+
"maxOutputTokens",
|
|
30
|
+
"maxDurationSeconds",
|
|
31
|
+
"maxQueueDepth",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function parseArgs(argv) {
|
|
35
|
+
const args = argv.slice(2);
|
|
36
|
+
const options = { baseUrl: null, limitsPath: null, json: false, checkCloaking: false, minLevel: null };
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < args.length; i++) {
|
|
39
|
+
if (args[i] === "--limits-path" && i + 1 < args.length) {
|
|
40
|
+
options.limitsPath = args[++i];
|
|
41
|
+
} else if (args[i] === "--min-level" && i + 1 < args.length) {
|
|
42
|
+
const parsed = Number.parseInt(args[++i], 10);
|
|
43
|
+
options.minLevel = Number.isInteger(parsed) ? parsed : null;
|
|
44
|
+
} else if (args[i] === "--json") {
|
|
45
|
+
options.json = true;
|
|
46
|
+
} else if (args[i] === "--check-cloaking") {
|
|
47
|
+
options.checkCloaking = true;
|
|
48
|
+
} else if (!args[i].startsWith("--")) {
|
|
49
|
+
options.baseUrl = args[i].replace(/\/$/, "");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return options;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function checkLimitsEndpoint(baseUrl, limitsPath) {
|
|
57
|
+
const paths = limitsPath ? [limitsPath] : DEFAULT_LIMITS_PATHS;
|
|
58
|
+
const results = [];
|
|
59
|
+
|
|
60
|
+
for (const path of paths) {
|
|
61
|
+
const url = `${baseUrl}${path}`;
|
|
62
|
+
try {
|
|
63
|
+
const response = await fetch(url, {
|
|
64
|
+
headers: { Accept: "application/json" },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
results.push({ path, status: response.status, found: false });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const body = await response.json();
|
|
73
|
+
const hasService = typeof body.service === "string" && body.service.length > 0;
|
|
74
|
+
const hasDescription = typeof body.description === "string" && body.description.length > 0;
|
|
75
|
+
const hasLimits = body.limits && typeof body.limits === "object" && !Array.isArray(body.limits);
|
|
76
|
+
const conformance = typeof body.conformance === "string" ? body.conformance : null;
|
|
77
|
+
const limitEntries = hasLimits ? Object.values(body.limits) : [];
|
|
78
|
+
|
|
79
|
+
const wellFormed = limitEntries.every((entry) => {
|
|
80
|
+
if (
|
|
81
|
+
typeof entry.endpoint !== "string" ||
|
|
82
|
+
entry.endpoint.length === 0 ||
|
|
83
|
+
typeof entry.method !== "string" ||
|
|
84
|
+
entry.method.length === 0 ||
|
|
85
|
+
!Array.isArray(entry.limits)
|
|
86
|
+
) return false;
|
|
87
|
+
return entry.limits.every(
|
|
88
|
+
(l) =>
|
|
89
|
+
typeof l.type === "string" &&
|
|
90
|
+
typeof l.maxRequests === "number" &&
|
|
91
|
+
typeof l.windowSeconds === "number" &&
|
|
92
|
+
typeof l.description === "string"
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const extensionCheck = checkExtensions(body.extensions, new URL(baseUrl).origin);
|
|
97
|
+
const bodyCheck = checkLimitsBody(body, new URL(baseUrl).origin);
|
|
98
|
+
const cacheControl = response.headers.get("cache-control") || "";
|
|
99
|
+
const isCacheable = /s-maxage=\d+/.test(cacheControl) || /max-age=\d+/.test(cacheControl);
|
|
100
|
+
|
|
101
|
+
results.push({
|
|
102
|
+
path,
|
|
103
|
+
status: response.status,
|
|
104
|
+
found: true,
|
|
105
|
+
hasService,
|
|
106
|
+
hasDescription,
|
|
107
|
+
hasLimits,
|
|
108
|
+
conformance,
|
|
109
|
+
limitCount: limitEntries.length,
|
|
110
|
+
wellFormed: hasService && hasDescription && wellFormed && bodyCheck.isValid,
|
|
111
|
+
extensions: extensionCheck,
|
|
112
|
+
isCacheable,
|
|
113
|
+
warnings: bodyCheck.warnings,
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
results.push({ path, status: 0, found: false, error: error.message });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return results;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const REQUIRED_RESPONSE_FIELDS = ["error", "detail", "why"];
|
|
124
|
+
|
|
125
|
+
function checkResponseBody(body, responseClass) {
|
|
126
|
+
if (!body || typeof body !== "object") {
|
|
127
|
+
return {
|
|
128
|
+
isValid: false,
|
|
129
|
+
missingFields: REQUIRED_RESPONSE_FIELDS,
|
|
130
|
+
errors: ["Body is not an object"],
|
|
131
|
+
warnings: [],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const errors = [];
|
|
136
|
+
const warnings = [];
|
|
137
|
+
|
|
138
|
+
const missing = REQUIRED_RESPONSE_FIELDS.filter((f) => {
|
|
139
|
+
if (typeof body[f] !== "string") return true;
|
|
140
|
+
if (body[f].length === 0) return true;
|
|
141
|
+
if (f === "error" && !isStableErrorValue(body[f])) return true;
|
|
142
|
+
return false;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (missing.length > 0) {
|
|
146
|
+
errors.push(`Missing required fields: ${missing.join(", ")}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Quality: why should explain, not restate the error
|
|
150
|
+
if (body.why && body.error) {
|
|
151
|
+
const errorWords = body.error.toLowerCase().replace(/_/g, " ");
|
|
152
|
+
if (body.why.toLowerCase().includes(errorWords)) {
|
|
153
|
+
warnings.push("'why' restates the error instead of explaining the reason");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Class-specific checks
|
|
158
|
+
if (responseClass === "limit") {
|
|
159
|
+
if (typeof body.limit !== "string" || body.limit.length === 0) {
|
|
160
|
+
errors.push("Limit class requires 'limit' field");
|
|
161
|
+
}
|
|
162
|
+
if (!Number.isInteger(body.retryAfterSeconds) || body.retryAfterSeconds < 0) {
|
|
163
|
+
errors.push("Limit class requires 'retryAfterSeconds' (non-negative integer)");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (responseClass === "input" && body.allowedMethods) {
|
|
168
|
+
if (!Array.isArray(body.allowedMethods)) {
|
|
169
|
+
warnings.push("'allowedMethods' should be an array");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (responseClass === "availability" && body.retryAfterSeconds !== undefined) {
|
|
174
|
+
if (!Number.isInteger(body.retryAfterSeconds) || body.retryAfterSeconds < 0) {
|
|
175
|
+
warnings.push("'retryAfterSeconds' should be a non-negative integer");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
isValid: errors.length === 0,
|
|
181
|
+
missingFields: missing,
|
|
182
|
+
errors,
|
|
183
|
+
warnings,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function checkDedupResponse(body) {
|
|
188
|
+
if (!body || typeof body !== "object") {
|
|
189
|
+
return { isDedup: false, errors: ["Body is not an object"] };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const errors = [];
|
|
193
|
+
const hasRescanBlocked = "_rescanBlocked" in body && body._rescanBlocked === true;
|
|
194
|
+
const hasCacheStatus = "_cacheStatus" in body && typeof body._cacheStatus === "string";
|
|
195
|
+
const hasResultData = Object.keys(body).some(
|
|
196
|
+
(k) => !k.startsWith("_") && k !== "error" && k !== "detail"
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
if (!hasRescanBlocked) errors.push("Missing '_rescanBlocked: true' flag");
|
|
200
|
+
if (!hasResultData) errors.push("Dedup response should include the original result data");
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
isDedup: hasRescanBlocked,
|
|
204
|
+
hasRescanBlocked,
|
|
205
|
+
hasCacheStatus,
|
|
206
|
+
hasResultData,
|
|
207
|
+
errors,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function checkHtmlRefusal(html) {
|
|
212
|
+
if (!html || typeof html !== "string") {
|
|
213
|
+
return {
|
|
214
|
+
hasRetryMeta: false,
|
|
215
|
+
retrySeconds: null,
|
|
216
|
+
hasJsonAlternate: false,
|
|
217
|
+
jsonAlternateUrl: null,
|
|
218
|
+
errors: ["Input is not a string"],
|
|
219
|
+
warnings: [],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const errors = [];
|
|
224
|
+
const warnings = [];
|
|
225
|
+
|
|
226
|
+
// Check for <meta name="retry-after" content="N">
|
|
227
|
+
const metaMatch = html.match(/<meta\s+name=["']retry-after["']\s+content=["'](\d+)["']\s*\/?>/i);
|
|
228
|
+
const hasRetryMeta = !!metaMatch;
|
|
229
|
+
const retrySeconds = metaMatch ? parseInt(metaMatch[1], 10) : null;
|
|
230
|
+
|
|
231
|
+
if (hasRetryMeta && isNaN(retrySeconds)) {
|
|
232
|
+
errors.push("retry-after meta content is not a valid number");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Check for <link rel="alternate" type="application/json" href="...">
|
|
236
|
+
const linkMatch = html.match(/<link\s+[^>]*rel=["']alternate["'][^>]*>/i);
|
|
237
|
+
let hasJsonAlternate = false;
|
|
238
|
+
let jsonAlternateUrl = null;
|
|
239
|
+
|
|
240
|
+
if (linkMatch) {
|
|
241
|
+
const linkTag = linkMatch[0];
|
|
242
|
+
const typeMatch = linkTag.match(/type=["']([^"']+)["']/i);
|
|
243
|
+
const hrefMatch = linkTag.match(/href=["']([^"']+)["']/i);
|
|
244
|
+
|
|
245
|
+
if (typeMatch && typeMatch[1] === "application/json") {
|
|
246
|
+
hasJsonAlternate = true;
|
|
247
|
+
jsonAlternateUrl = hrefMatch ? hrefMatch[1] : null;
|
|
248
|
+
} else if (typeMatch) {
|
|
249
|
+
warnings.push(`Alternate link has type "${typeMatch[1]}" instead of "application/json"`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!hasRetryMeta && !hasJsonAlternate) {
|
|
254
|
+
errors.push("HTML 429 has neither retry-after meta nor JSON alternate link — not machine-accessible");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
hasRetryMeta,
|
|
259
|
+
retrySeconds,
|
|
260
|
+
hasJsonAlternate,
|
|
261
|
+
jsonAlternateUrl,
|
|
262
|
+
errors,
|
|
263
|
+
warnings,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isStableErrorValue(error) {
|
|
268
|
+
if (typeof error !== "string" || error.length === 0) return false;
|
|
269
|
+
return /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/.test(error);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function containsControlCharacters(value) {
|
|
273
|
+
return /[\u0000-\u001F\u007F]/.test(value);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function hasEncodedProtocolRelativePrefix(value) {
|
|
277
|
+
const lower = value.toLowerCase();
|
|
278
|
+
return lower.startsWith("/%2f") || lower.startsWith("/%5c") || lower.startsWith("%2f%2f") || lower.startsWith("%5c%5c");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function isSafeSameOriginOrRelativeUrl(url, origin) {
|
|
282
|
+
if (typeof url !== "string") return false;
|
|
283
|
+
if (url.length === 0 || url.trim() !== url) return false;
|
|
284
|
+
if (containsControlCharacters(url) || /\\/.test(url)) return false;
|
|
285
|
+
if (hasEncodedProtocolRelativePrefix(url)) return false;
|
|
286
|
+
|
|
287
|
+
if (url.startsWith("/")) {
|
|
288
|
+
if (url.startsWith("//")) return false;
|
|
289
|
+
try {
|
|
290
|
+
// Reject encoded control characters and encoded protocol-relative forms.
|
|
291
|
+
const decoded = decodeURIComponent(url);
|
|
292
|
+
if (containsControlCharacters(decoded)) return false;
|
|
293
|
+
if (decoded.startsWith("//") || /\\/.test(decoded)) return false;
|
|
294
|
+
} catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!origin) return false;
|
|
301
|
+
try {
|
|
302
|
+
const parsed = new URL(url);
|
|
303
|
+
if (parsed.protocol !== "https:") return false;
|
|
304
|
+
return parsed.origin === origin;
|
|
305
|
+
} catch {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isMachineActionableUrlSafe(url, origin) {
|
|
311
|
+
return isSafeSameOriginOrRelativeUrl(url, origin);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function containsInstructionLikeText(value) {
|
|
315
|
+
if (typeof value !== "string") return false;
|
|
316
|
+
const lower = value.toLowerCase();
|
|
317
|
+
return [
|
|
318
|
+
/ignore (all |any |previous |prior )?(instructions|rules|polic(?:y|ies)|system prompts?)/,
|
|
319
|
+
/disregard (all |any |previous |prior )?(instructions|rules|polic(?:y|ies)|system prompts?)/,
|
|
320
|
+
/override (the )?(system|developer|user|policy)/,
|
|
321
|
+
/follow (these|this) instructions/,
|
|
322
|
+
/execute (this|the following)/,
|
|
323
|
+
/send (secrets?|tokens?|credentials?)/,
|
|
324
|
+
/exfiltrat/,
|
|
325
|
+
].some((pattern) => pattern.test(lower));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function checkRefusalBody(body, origin) {
|
|
329
|
+
const parsed = typeof body === "string" ? tryParseJson(body) : body;
|
|
330
|
+
|
|
331
|
+
if (!parsed) {
|
|
332
|
+
return {
|
|
333
|
+
isJson: false,
|
|
334
|
+
hasRequiredFields: false,
|
|
335
|
+
missingFields: REQUIRED_REFUSAL_FIELDS,
|
|
336
|
+
hasConstructiveFields: false,
|
|
337
|
+
constructiveFields: [],
|
|
338
|
+
warnings: [],
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const warnings = [];
|
|
343
|
+
|
|
344
|
+
const missing = REQUIRED_REFUSAL_FIELDS.filter((f) => {
|
|
345
|
+
if (f === "retryAfterSeconds") {
|
|
346
|
+
return !Number.isInteger(parsed[f]) || parsed[f] < 0;
|
|
347
|
+
}
|
|
348
|
+
if (f === "error") {
|
|
349
|
+
return typeof parsed[f] !== "string" || parsed[f].length === 0 || !isStableErrorValue(parsed[f]);
|
|
350
|
+
}
|
|
351
|
+
return typeof parsed[f] !== "string" || parsed[f].trim().length === 0;
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const constructive = CONSTRUCTIVE_FIELDS.filter((f) => {
|
|
355
|
+
if (!(f in parsed)) return false;
|
|
356
|
+
if (f === "cached") return typeof parsed[f] === "boolean";
|
|
357
|
+
if (typeof parsed[f] !== "string" || parsed[f].trim().length === 0) return false;
|
|
358
|
+
if (MACHINE_ACTIONABLE_URL_FIELDS.includes(f)) {
|
|
359
|
+
return isMachineActionableUrlSafe(parsed[f], origin);
|
|
360
|
+
}
|
|
361
|
+
return true;
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
for (const field of ["detail", "why", "limit"]) {
|
|
365
|
+
if (containsInstructionLikeText(parsed[field])) {
|
|
366
|
+
warnings.push(`${field}: contains instruction-like text; treat as untrusted data`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
for (const field of MACHINE_ACTIONABLE_URL_FIELDS) {
|
|
370
|
+
if (field in parsed && typeof parsed[field] === "string" && containsInstructionLikeText(parsed[field])) {
|
|
371
|
+
warnings.push(`${field}: machine-actionable guidance contains instruction-like text`);
|
|
372
|
+
}
|
|
373
|
+
if (field in parsed && typeof parsed[field] === "string" && !isMachineActionableUrlSafe(parsed[field], origin)) {
|
|
374
|
+
warnings.push(`${field}: machine-actionable URL must be relative or same-origin`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
for (const field of CONSTRUCTIVE_FIELDS) {
|
|
378
|
+
if (field in parsed && field !== "cached" && typeof parsed[field] !== "string") {
|
|
379
|
+
warnings.push(`${field}: guidance field should be a string URL`);
|
|
380
|
+
}
|
|
381
|
+
if (field === "cached" && field in parsed && typeof parsed[field] !== "boolean") {
|
|
382
|
+
warnings.push("'cached' should be a boolean");
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if ("limitId" in parsed && typeof parsed.limitId !== "string") {
|
|
387
|
+
warnings.push("'limitId' should be a string when present");
|
|
388
|
+
}
|
|
389
|
+
if ("limitType" in parsed && typeof parsed.limitType !== "string") {
|
|
390
|
+
warnings.push("'limitType' should be a string when present");
|
|
391
|
+
}
|
|
392
|
+
if ("scope" in parsed && typeof parsed.scope !== "string") {
|
|
393
|
+
warnings.push("'scope' should be a string when present");
|
|
394
|
+
}
|
|
395
|
+
if ("windowResetAt" in parsed && !isValidResetTimestamp(parsed.windowResetAt)) {
|
|
396
|
+
warnings.push("'windowResetAt' should be an ISO timestamp string or non-negative Unix timestamp");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Quality checks
|
|
400
|
+
const whyIsNotRestated =
|
|
401
|
+
parsed.why &&
|
|
402
|
+
parsed.error &&
|
|
403
|
+
!parsed.why.toLowerCase().includes(parsed.error.toLowerCase().replace(/_/g, " "));
|
|
404
|
+
|
|
405
|
+
const detailIncludesTime =
|
|
406
|
+
parsed.detail && /\d+\s*(second|minute|hour)/i.test(parsed.detail);
|
|
407
|
+
|
|
408
|
+
const retryIsNumber =
|
|
409
|
+
typeof parsed.retryAfterSeconds === "number" && parsed.retryAfterSeconds >= 0;
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
isJson: true,
|
|
413
|
+
hasRequiredFields: missing.length === 0,
|
|
414
|
+
missingFields: missing,
|
|
415
|
+
hasConstructiveFields: constructive.length > 0,
|
|
416
|
+
constructiveFields: constructive,
|
|
417
|
+
warnings,
|
|
418
|
+
whyIsNotRestated: whyIsNotRestated !== false,
|
|
419
|
+
detailIncludesTime,
|
|
420
|
+
retryIsNumber,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function tryParseJson(str) {
|
|
425
|
+
try {
|
|
426
|
+
return JSON.parse(str);
|
|
427
|
+
} catch {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const VALID_CONFORMANCE_VALUES = [
|
|
433
|
+
"not-applicable", "none", "level-1", "level-2", "level-3", "level-4",
|
|
434
|
+
];
|
|
435
|
+
|
|
436
|
+
const REQUIRED_LIMIT_ENTRY_FIELDS = ["type", "maxRequests", "windowSeconds", "description"];
|
|
437
|
+
const VALID_ACTION_BOUNDARY_PROFILES = ["action-boundaries", "commercial-boundaries"];
|
|
438
|
+
const VALID_ACTION_STATUSES = ["allowed", "requires_approval", "unsupported", "human_only", "blocked"];
|
|
439
|
+
const VALID_ACTION_AUTHORITY_VALUES = [
|
|
440
|
+
"none",
|
|
441
|
+
"user",
|
|
442
|
+
"buyer",
|
|
443
|
+
"organization",
|
|
444
|
+
"admin",
|
|
445
|
+
"legal",
|
|
446
|
+
"user_or_organization",
|
|
447
|
+
];
|
|
448
|
+
const HUMAN_NAVIGATION_URL_FIELDS = ["humanUrl"];
|
|
449
|
+
|
|
450
|
+
function isExtensionUrlSafe(url, origin) {
|
|
451
|
+
return isSafeSameOriginOrRelativeUrl(url, origin);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function checkExtensions(extensions, origin) {
|
|
455
|
+
const errors = [];
|
|
456
|
+
const warnings = [];
|
|
457
|
+
|
|
458
|
+
if (extensions === undefined) {
|
|
459
|
+
return { present: false, keys: [], errors, warnings };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (!extensions || typeof extensions !== "object" || Array.isArray(extensions)) {
|
|
463
|
+
errors.push("'extensions' must be an object when present");
|
|
464
|
+
return { present: true, keys: [], errors, warnings };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const keys = Object.keys(extensions);
|
|
468
|
+
for (const key of keys) {
|
|
469
|
+
const value = extensions[key];
|
|
470
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
471
|
+
errors.push(`extensions.${key}: must be a non-empty string URL`);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (!isExtensionUrlSafe(value, origin)) {
|
|
475
|
+
errors.push(`extensions.${key}: URL must be relative or same-origin`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return { present: true, keys, errors, warnings };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function checkActionBoundariesBody(body, origin) {
|
|
483
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
484
|
+
return { isValid: false, errors: ["Body is not an object"], warnings: [] };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const errors = [];
|
|
488
|
+
const warnings = [];
|
|
489
|
+
const profile = typeof body.profile === "string" ? body.profile : null;
|
|
490
|
+
|
|
491
|
+
if (typeof body.service !== "string" || body.service.length === 0) {
|
|
492
|
+
errors.push("Missing 'service' field");
|
|
493
|
+
}
|
|
494
|
+
if (!profile || !VALID_ACTION_BOUNDARY_PROFILES.includes(profile)) {
|
|
495
|
+
errors.push(`Invalid profile: "${profile}"`);
|
|
496
|
+
}
|
|
497
|
+
if (typeof body.version !== "string" || body.version.length === 0) {
|
|
498
|
+
errors.push("Missing 'version' field");
|
|
499
|
+
}
|
|
500
|
+
if (typeof body.updatedAt !== "string" || Number.isNaN(Date.parse(body.updatedAt))) {
|
|
501
|
+
errors.push("Missing or invalid 'updatedAt' timestamp");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
checkUrlFields(body, origin, errors, "root");
|
|
505
|
+
|
|
506
|
+
const trustClaims = findBoundaryTrustClaims(body);
|
|
507
|
+
errors.push(...trustClaims.map((claim) => `Boundary documents must not claim trust, identity, authority, payment safety, or authorization: ${claim}`));
|
|
508
|
+
|
|
509
|
+
if (profile === "action-boundaries") {
|
|
510
|
+
validateActionMap(body.actions, origin, errors, "actions");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (profile === "commercial-boundaries") {
|
|
514
|
+
validateActionMap(body.commercialTasks, origin, errors, "commercialTasks");
|
|
515
|
+
validateCommercialBoundarySections(body, origin, errors);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
isValid: errors.length === 0,
|
|
520
|
+
errors,
|
|
521
|
+
warnings,
|
|
522
|
+
profile,
|
|
523
|
+
actionCount: countActionEntries(profile === "commercial-boundaries" ? body.commercialTasks : body.actions),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function validateActionMap(actions, origin, errors, path) {
|
|
528
|
+
if (!actions || typeof actions !== "object" || Array.isArray(actions)) {
|
|
529
|
+
errors.push(`Missing or invalid '${path}' object`);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
for (const [key, action] of Object.entries(actions)) {
|
|
534
|
+
const actionPath = `${path}.${key}`;
|
|
535
|
+
if (!action || typeof action !== "object" || Array.isArray(action)) {
|
|
536
|
+
errors.push(`${actionPath}: must be an object`);
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (!VALID_ACTION_STATUSES.includes(action.status)) {
|
|
540
|
+
errors.push(`${actionPath}: invalid status "${action.status}"`);
|
|
541
|
+
}
|
|
542
|
+
if (
|
|
543
|
+
"authorityRequired" in action &&
|
|
544
|
+
!VALID_ACTION_AUTHORITY_VALUES.includes(action.authorityRequired)
|
|
545
|
+
) {
|
|
546
|
+
errors.push(`${actionPath}: invalid authorityRequired "${action.authorityRequired}"`);
|
|
547
|
+
}
|
|
548
|
+
if ("approvalThresholds" in action) {
|
|
549
|
+
validateApprovalThresholds(action.approvalThresholds, errors, `${actionPath}.approvalThresholds`);
|
|
550
|
+
}
|
|
551
|
+
checkUrlFields(action, origin, errors, actionPath);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function validateApprovalThresholds(thresholds, errors, path) {
|
|
556
|
+
if (!Array.isArray(thresholds)) {
|
|
557
|
+
errors.push(`${path}: must be an array`);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
thresholds.forEach((threshold, index) => {
|
|
562
|
+
const thresholdPath = `${path}[${index}]`;
|
|
563
|
+
if (!threshold || typeof threshold !== "object" || Array.isArray(threshold)) {
|
|
564
|
+
errors.push(`${thresholdPath}: must be an object`);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (typeof threshold.maxAmount !== "number" || threshold.maxAmount < 0) {
|
|
568
|
+
errors.push(`${thresholdPath}: missing or invalid maxAmount`);
|
|
569
|
+
}
|
|
570
|
+
if (typeof threshold.currency !== "string" || threshold.currency.length === 0) {
|
|
571
|
+
errors.push(`${thresholdPath}: missing or invalid currency`);
|
|
572
|
+
}
|
|
573
|
+
if (typeof threshold.approval !== "string" || threshold.approval.length === 0) {
|
|
574
|
+
errors.push(`${thresholdPath}: missing or invalid approval`);
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function validateCommercialBoundarySections(body, origin, errors) {
|
|
580
|
+
if (body.legibility !== undefined) {
|
|
581
|
+
validateBooleanObject(body.legibility, errors, "legibility");
|
|
582
|
+
checkUrlFields(body.legibility, origin, errors, "legibility");
|
|
583
|
+
}
|
|
584
|
+
if (body.recourse !== undefined) {
|
|
585
|
+
validateBooleanObject(body.recourse, errors, "recourse");
|
|
586
|
+
checkUrlFields(body.recourse, origin, errors, "recourse");
|
|
587
|
+
}
|
|
588
|
+
if (body.audit !== undefined) {
|
|
589
|
+
validateBooleanObject(body.audit, errors, "audit");
|
|
590
|
+
checkUrlFields(body.audit, origin, errors, "audit");
|
|
591
|
+
}
|
|
592
|
+
if (body.fraudBoundary !== undefined) {
|
|
593
|
+
validateBooleanObject(body.fraudBoundary, errors, "fraudBoundary");
|
|
594
|
+
checkUrlFields(body.fraudBoundary, origin, errors, "fraudBoundary");
|
|
595
|
+
}
|
|
596
|
+
if (body.payment !== undefined && (!body.payment || typeof body.payment !== "object" || Array.isArray(body.payment))) {
|
|
597
|
+
errors.push("payment: must be an object");
|
|
598
|
+
}
|
|
599
|
+
if (body.payment && body.payment.acceptedShapes !== undefined && !Array.isArray(body.payment.acceptedShapes)) {
|
|
600
|
+
errors.push("payment.acceptedShapes: must be an array");
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function validateBooleanObject(value, errors, path) {
|
|
605
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
606
|
+
errors.push(`${path}: must be an object`);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
for (const [key, item] of Object.entries(value)) {
|
|
611
|
+
if (key.endsWith("Url") || key.endsWith("Path")) {
|
|
612
|
+
if (typeof item !== "string") {
|
|
613
|
+
errors.push(`${path}.${key}: must be a string URL`);
|
|
614
|
+
}
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (typeof item !== "boolean" && typeof item !== "string" && !Array.isArray(item)) {
|
|
618
|
+
errors.push(`${path}.${key}: must be a boolean, string, or array`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function checkUrlFields(value, origin, errors, path) {
|
|
624
|
+
if (!value || typeof value !== "object") return;
|
|
625
|
+
|
|
626
|
+
for (const [key, item] of Object.entries(value)) {
|
|
627
|
+
if ((key.endsWith("Url") || key.endsWith("Path")) && item !== undefined && typeof item !== "string") {
|
|
628
|
+
errors.push(`${path}.${key}: must be a string URL`);
|
|
629
|
+
}
|
|
630
|
+
if (key.endsWith("Url") && typeof item === "string") {
|
|
631
|
+
if (HUMAN_NAVIGATION_URL_FIELDS.includes(key)) continue;
|
|
632
|
+
if (!isExtensionUrlSafe(item, origin)) {
|
|
633
|
+
errors.push(`${path}.${key}: URL must be relative or same-origin`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function countActionEntries(actions) {
|
|
640
|
+
if (!actions || typeof actions !== "object" || Array.isArray(actions)) return 0;
|
|
641
|
+
return Object.keys(actions).length;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function findBoundaryTrustClaims(body) {
|
|
645
|
+
const text = JSON.stringify(body || {}).toLowerCase();
|
|
646
|
+
const claims = [
|
|
647
|
+
["verified buyer", /\bverified\s*buyer\b/],
|
|
648
|
+
["authenticated organization", /\bauthenticated\s*organization\b/],
|
|
649
|
+
["authority verified", /\bauthority\s*verified\b/],
|
|
650
|
+
["identity confirmed", /\bidentity\s*confirmed\b/],
|
|
651
|
+
["confirms identity", /\bconfirms?\s*identity\b/],
|
|
652
|
+
["certified", /\bcertified\b/],
|
|
653
|
+
["recommended merchant", /\brecommended\s*merchant\b/],
|
|
654
|
+
["trusted seller", /\btrusted\s*seller\b/],
|
|
655
|
+
["approved vendor", /\bapproved\s*vendor\b/],
|
|
656
|
+
["payment safe", /\bpayment\s*safe\b/],
|
|
657
|
+
["merchant verified", /\bmerchant\s*verified\b/],
|
|
658
|
+
["authorized caller", /\bauthorized\s*caller\b/],
|
|
659
|
+
];
|
|
660
|
+
return claims.filter(([, pattern]) => pattern.test(text)).map(([label]) => label);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function isValidResetTimestamp(value) {
|
|
664
|
+
if (typeof value === "number") return Number.isFinite(value) && value >= 0;
|
|
665
|
+
if (typeof value === "string") return value.length > 0 && !Number.isNaN(Date.parse(value));
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function checkLimitsBody(body, origin) {
|
|
670
|
+
if (!body || typeof body !== "object") {
|
|
671
|
+
return { isValid: false, errors: ["Body is not an object"] };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const errors = [];
|
|
675
|
+
const warnings = [];
|
|
676
|
+
|
|
677
|
+
const hasService = typeof body.service === "string" && body.service.length > 0;
|
|
678
|
+
const hasDescription = typeof body.description === "string" && body.description.length > 0;
|
|
679
|
+
const hasLimits = body.limits && typeof body.limits === "object" && !Array.isArray(body.limits);
|
|
680
|
+
const conformance = typeof body.conformance === "string" ? body.conformance : null;
|
|
681
|
+
const extensionCheck = checkExtensions(body.extensions, origin);
|
|
682
|
+
|
|
683
|
+
if (!hasService) errors.push("Missing 'service' field");
|
|
684
|
+
if (!hasDescription) errors.push("Missing 'description' field");
|
|
685
|
+
if (!hasLimits) errors.push("Missing or invalid 'limits' object");
|
|
686
|
+
errors.push(...extensionCheck.errors);
|
|
687
|
+
warnings.push(...extensionCheck.warnings);
|
|
688
|
+
|
|
689
|
+
// Validate conformance value
|
|
690
|
+
let conformanceValid = true;
|
|
691
|
+
if (conformance !== null && !VALID_CONFORMANCE_VALUES.includes(conformance)) {
|
|
692
|
+
errors.push(`Invalid conformance value: "${conformance}"`);
|
|
693
|
+
conformanceValid = false;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Validate limit entries
|
|
697
|
+
const limitEntries = hasLimits ? Object.entries(body.limits) : [];
|
|
698
|
+
const entryErrors = [];
|
|
699
|
+
|
|
700
|
+
for (const [key, entry] of limitEntries) {
|
|
701
|
+
if (!entry.endpoint || typeof entry.endpoint !== "string") {
|
|
702
|
+
entryErrors.push(`${key}: missing or invalid 'endpoint'`);
|
|
703
|
+
}
|
|
704
|
+
if (!entry.method || typeof entry.method !== "string") {
|
|
705
|
+
entryErrors.push(`${key}: missing or invalid 'method'`);
|
|
706
|
+
}
|
|
707
|
+
if (!Array.isArray(entry.limits)) {
|
|
708
|
+
entryErrors.push(`${key}: missing or invalid 'limits' array`);
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
for (let i = 0; i < entry.limits.length; i++) {
|
|
712
|
+
const limit = entry.limits[i];
|
|
713
|
+
const missing = REQUIRED_LIMIT_ENTRY_FIELDS.filter((f) => {
|
|
714
|
+
if (f === "maxRequests" || f === "windowSeconds") return typeof limit[f] !== "number";
|
|
715
|
+
return typeof limit[f] !== "string" || limit[f].length === 0;
|
|
716
|
+
});
|
|
717
|
+
if (missing.length > 0) {
|
|
718
|
+
entryErrors.push(`${key}.limits[${i}]: missing ${missing.join(", ")}`);
|
|
719
|
+
}
|
|
720
|
+
// v1.1: validate returnsCached on resource-dedup entries
|
|
721
|
+
if ("returnsCached" in limit) {
|
|
722
|
+
if (typeof limit.returnsCached !== "boolean") {
|
|
723
|
+
warnings.push(`${key}.limits[${i}]: 'returnsCached' should be a boolean`);
|
|
724
|
+
}
|
|
725
|
+
if (limit.type && limit.type !== "resource-dedup") {
|
|
726
|
+
warnings.push(`${key}.limits[${i}]: 'returnsCached' is only meaningful on resource-dedup limits`);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
for (const field of OPTIONAL_LIMIT_STRING_FIELDS) {
|
|
730
|
+
if (field in limit && (typeof limit[field] !== "string" || limit[field].length === 0)) {
|
|
731
|
+
warnings.push(`${key}.limits[${i}]: '${field}' should be a non-empty string`);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
for (const field of OPTIONAL_LIMIT_NUMBER_FIELDS) {
|
|
735
|
+
if (field in limit && (typeof limit[field] !== "number" || limit[field] < 0)) {
|
|
736
|
+
warnings.push(`${key}.limits[${i}]: '${field}' should be a non-negative number`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
if ("windowResetAt" in limit && !isValidResetTimestamp(limit.windowResetAt)) {
|
|
740
|
+
warnings.push(`${key}.limits[${i}]: 'windowResetAt' should be an ISO timestamp string or non-negative Unix timestamp`);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Validate optional metadata fields (v1.1)
|
|
746
|
+
const hasChangelog = "changelog" in body;
|
|
747
|
+
const hasFeed = "feed" in body;
|
|
748
|
+
if (hasChangelog) {
|
|
749
|
+
if (!isExtensionUrlSafe(body.changelog, origin)) {
|
|
750
|
+
warnings.push("'changelog' field present but not a safe relative or same-origin URL");
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (hasFeed) {
|
|
754
|
+
if (!isExtensionUrlSafe(body.feed, origin)) {
|
|
755
|
+
warnings.push("'feed' field present but not a safe relative or same-origin URL");
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Consistency checks
|
|
760
|
+
let conformanceConsistent = true;
|
|
761
|
+
if (conformance === "not-applicable" && limitEntries.length > 0) {
|
|
762
|
+
errors.push("Declares not-applicable but lists limits");
|
|
763
|
+
conformanceConsistent = false;
|
|
764
|
+
}
|
|
765
|
+
if (conformance === "none" && limitEntries.length > 0) {
|
|
766
|
+
// A "none" declaration with populated limits means the site is better than it claims
|
|
767
|
+
warnings.push("Declares 'none' but has populated limits — may understate conformance");
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
isValid: errors.length === 0 && entryErrors.length === 0,
|
|
772
|
+
hasService,
|
|
773
|
+
hasDescription,
|
|
774
|
+
hasLimits,
|
|
775
|
+
hasChangelog,
|
|
776
|
+
hasFeed,
|
|
777
|
+
hasExtensions: extensionCheck.present,
|
|
778
|
+
extensions: extensionCheck,
|
|
779
|
+
conformance,
|
|
780
|
+
conformanceValid,
|
|
781
|
+
conformanceConsistent,
|
|
782
|
+
limitCount: limitEntries.length,
|
|
783
|
+
entryErrors,
|
|
784
|
+
errors,
|
|
785
|
+
warnings,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function checkProactiveHeaders(headers) {
|
|
790
|
+
if (!headers || typeof headers !== "object") {
|
|
791
|
+
return { hasProactiveHeaders: false, errors: ["No headers provided"] };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const errors = [];
|
|
795
|
+
|
|
796
|
+
// Normalize header names to lowercase
|
|
797
|
+
const normalized = {};
|
|
798
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
799
|
+
normalized[k.toLowerCase()] = v;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const ratelimit = normalized["ratelimit"] || null;
|
|
803
|
+
const policy = normalized["ratelimit-policy"] || null;
|
|
804
|
+
|
|
805
|
+
if (!ratelimit) {
|
|
806
|
+
return { hasProactiveHeaders: false, errors: ["Missing RateLimit header"] };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Parse RateLimit header: "limit=N, remaining=N, reset=N"
|
|
810
|
+
const parts = ratelimit.split(",").map((s) => s.trim());
|
|
811
|
+
const limitPart = parts.find((p) => p.startsWith("limit="));
|
|
812
|
+
const remainingPart = parts.find((p) => p.startsWith("remaining="));
|
|
813
|
+
const resetPart = parts.find((p) => p.startsWith("reset="));
|
|
814
|
+
|
|
815
|
+
if (!limitPart) errors.push("RateLimit header missing 'limit' component");
|
|
816
|
+
if (!remainingPart) errors.push("RateLimit header missing 'remaining' component");
|
|
817
|
+
if (!resetPart) errors.push("RateLimit header missing 'reset' component");
|
|
818
|
+
|
|
819
|
+
const limitValue = limitPart ? parseInt(limitPart.split("=")[1], 10) : null;
|
|
820
|
+
const remainingValue = remainingPart ? parseInt(remainingPart.split("=")[1], 10) : null;
|
|
821
|
+
const resetValue = resetPart ? parseInt(resetPart.split("=")[1], 10) : null;
|
|
822
|
+
|
|
823
|
+
if (limitValue !== null && isNaN(limitValue)) errors.push("RateLimit 'limit' is not a number");
|
|
824
|
+
if (remainingValue !== null && isNaN(remainingValue)) errors.push("RateLimit 'remaining' is not a number");
|
|
825
|
+
if (resetValue !== null && isNaN(resetValue)) errors.push("RateLimit 'reset' is not a number");
|
|
826
|
+
|
|
827
|
+
if (limitValue !== null && remainingValue !== null && remainingValue > limitValue) {
|
|
828
|
+
errors.push("RateLimit 'remaining' exceeds 'limit'");
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Validate RateLimit-Policy if present (advisory, does not block hasProactiveHeaders)
|
|
832
|
+
const warnings = [];
|
|
833
|
+
let policyValid = null;
|
|
834
|
+
if (policy) {
|
|
835
|
+
// Expected format: "N;w=N"
|
|
836
|
+
policyValid = /^\d+;w=\d+$/.test(policy.trim());
|
|
837
|
+
if (!policyValid) warnings.push(`RateLimit-Policy format invalid: "${policy}"`);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
return {
|
|
841
|
+
hasProactiveHeaders: errors.length === 0 && limitPart && remainingPart && resetPart,
|
|
842
|
+
ratelimit,
|
|
843
|
+
policy,
|
|
844
|
+
limitValue,
|
|
845
|
+
remainingValue,
|
|
846
|
+
resetValue,
|
|
847
|
+
policyValid,
|
|
848
|
+
errors,
|
|
849
|
+
warnings,
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function normalizeCoreText(text) {
|
|
854
|
+
if (typeof text !== "string") return "";
|
|
855
|
+
return text
|
|
856
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
857
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
858
|
+
.replace(/<[^>]+>/g, " ")
|
|
859
|
+
.replace(/&[a-z0-9#]+;/gi, " ")
|
|
860
|
+
.toLowerCase()
|
|
861
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
862
|
+
.trim();
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function tokenizeCoreText(text) {
|
|
866
|
+
const normalized = normalizeCoreText(text);
|
|
867
|
+
if (!normalized) return [];
|
|
868
|
+
return normalized.split(/\s+/).filter((token) => token.length >= 3);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function checkContentContainment(html, alternate, threshold = 0.6) {
|
|
872
|
+
const htmlTokens = tokenizeCoreText(html);
|
|
873
|
+
const alternateTokens = new Set(tokenizeCoreText(alternate));
|
|
874
|
+
|
|
875
|
+
if (htmlTokens.length === 0) {
|
|
876
|
+
return {
|
|
877
|
+
containment: 0,
|
|
878
|
+
threshold,
|
|
879
|
+
likelyCloaking: false,
|
|
880
|
+
warnings: ["HTML content has no comparable core text"],
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const matched = htmlTokens.filter((token) => alternateTokens.has(token)).length;
|
|
885
|
+
const containment = matched / htmlTokens.length;
|
|
886
|
+
const likelyCloaking = containment < threshold;
|
|
887
|
+
return {
|
|
888
|
+
containment,
|
|
889
|
+
threshold,
|
|
890
|
+
likelyCloaking,
|
|
891
|
+
warnings: likelyCloaking ? [`Content containment ${containment.toFixed(2)} is below ${threshold}`] : [],
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
async function checkContentCloaking(baseUrl) {
|
|
896
|
+
const response = await fetch(baseUrl, { headers: { Accept: "text/html" } });
|
|
897
|
+
const html = await response.text();
|
|
898
|
+
const alternateResponse = await fetch(baseUrl, { headers: { Accept: "text/markdown" } });
|
|
899
|
+
const alternate = await alternateResponse.text();
|
|
900
|
+
return {
|
|
901
|
+
htmlStatus: response.status,
|
|
902
|
+
alternateStatus: alternateResponse.status,
|
|
903
|
+
...checkContentContainment(html, alternate),
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function assessLevel(limitsResults, refusalCheck, proactiveHeaders) {
|
|
908
|
+
const limitsFound = limitsResults.some((r) => r.found && r.wellFormed);
|
|
909
|
+
|
|
910
|
+
// Check for a declared conformance of "not-applicable"
|
|
911
|
+
const limitsBody = limitsResults.find((r) => r.found && r.conformance);
|
|
912
|
+
if (limitsBody && limitsBody.conformance === "not-applicable") {
|
|
913
|
+
// N/A is valid only if there are no limits declared
|
|
914
|
+
if (limitsBody.limitCount === 0) return "not-applicable";
|
|
915
|
+
// A site declaring N/A but listing limits is misclassified — fall through
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const level1 = refusalCheck
|
|
919
|
+
? refusalCheck.isJson && refusalCheck.hasRequiredFields
|
|
920
|
+
: null;
|
|
921
|
+
|
|
922
|
+
const level2 = level1 && limitsFound;
|
|
923
|
+
const level3 = level2 && (refusalCheck ? refusalCheck.hasConstructiveFields : false);
|
|
924
|
+
const level4 = level3 && proactiveHeaders && proactiveHeaders.hasProactiveHeaders;
|
|
925
|
+
|
|
926
|
+
if (level4) return 4;
|
|
927
|
+
if (level3) return 3;
|
|
928
|
+
if (level2) return 2;
|
|
929
|
+
if (level1) return 1;
|
|
930
|
+
return 0;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async function main() {
|
|
934
|
+
const options = parseArgs(process.argv);
|
|
935
|
+
|
|
936
|
+
if (!options.baseUrl) {
|
|
937
|
+
console.error("Usage: node evals/check.js <base-url> [--limits-path /path] [--json] [--check-cloaking] [--min-level N]");
|
|
938
|
+
console.error("");
|
|
939
|
+
console.error("Examples:");
|
|
940
|
+
console.error(" node evals/check.js https://siteline.to");
|
|
941
|
+
console.error(" node evals/check.js https://your-api.com --limits-path /.well-known/limits --json");
|
|
942
|
+
console.error(" node evals/check.js https://your-api.com --min-level 2");
|
|
943
|
+
process.exit(1);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const report = {
|
|
947
|
+
target: options.baseUrl,
|
|
948
|
+
checkedAt: new Date().toISOString(),
|
|
949
|
+
limitsDiscovery: null,
|
|
950
|
+
refusalFormat: null,
|
|
951
|
+
conformanceLevel: 0,
|
|
952
|
+
contentCloaking: null,
|
|
953
|
+
notes: [],
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
// Check limits discovery endpoint
|
|
957
|
+
console.error(`Checking limits discovery at ${options.baseUrl}...`);
|
|
958
|
+
const limitsResults = await checkLimitsEndpoint(options.baseUrl, options.limitsPath);
|
|
959
|
+
report.limitsDiscovery = limitsResults;
|
|
960
|
+
|
|
961
|
+
const foundLimits = limitsResults.find((r) => r.found);
|
|
962
|
+
if (foundLimits) {
|
|
963
|
+
console.error(` Found at ${foundLimits.path} (${foundLimits.limitCount} endpoint(s) documented)`);
|
|
964
|
+
if (!foundLimits.wellFormed) {
|
|
965
|
+
report.notes.push("Limits endpoint exists but entries are not well-formed per spec.");
|
|
966
|
+
}
|
|
967
|
+
if (!foundLimits.isCacheable) {
|
|
968
|
+
report.notes.push("Limits endpoint is not cacheable. Consider adding Cache-Control headers.");
|
|
969
|
+
}
|
|
970
|
+
if (foundLimits.warnings && foundLimits.warnings.length > 0) {
|
|
971
|
+
report.notes.push(`Limits endpoint warnings: ${foundLimits.warnings.join("; ")}`);
|
|
972
|
+
}
|
|
973
|
+
} else {
|
|
974
|
+
console.error(" No limits discovery endpoint found.");
|
|
975
|
+
report.notes.push("No limits discovery endpoint found at standard paths.");
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// Note: We can't easily trigger a real 429 to test refusal format
|
|
979
|
+
// without hammering the service. Instead, we document what to check.
|
|
980
|
+
|
|
981
|
+
// Check for N/A declaration
|
|
982
|
+
if (foundLimits && foundLimits.conformance === "not-applicable") {
|
|
983
|
+
if (foundLimits.limitCount === 0) {
|
|
984
|
+
report.conformanceLevel = "not-applicable";
|
|
985
|
+
report.notes.push("Site declares not-applicable: no agentic interaction surface.");
|
|
986
|
+
} else {
|
|
987
|
+
report.notes.push(
|
|
988
|
+
"Site declares not-applicable but lists limits. Declaration is inconsistent — evaluating as a conforming service."
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// If we found a limits endpoint, we can at least determine Level 2 eligibility
|
|
994
|
+
if (report.conformanceLevel !== "not-applicable") {
|
|
995
|
+
if (foundLimits && foundLimits.wellFormed) {
|
|
996
|
+
report.conformanceLevel = 2;
|
|
997
|
+
report.notes.push("Discovery endpoint verified. At least Level 2 (Discoverable).");
|
|
998
|
+
report.notes.push(
|
|
999
|
+
"Level 1 (structured refusal) and Level 3 (constructive guidance) require a 429 response to verify. " +
|
|
1000
|
+
"Trigger a rate limit and check the response shape, or use --json for machine-readable output."
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
// Check proactive headers (Level 4) by hitting documented endpoints
|
|
1004
|
+
const limitsUrl = `${options.baseUrl}${foundLimits.path}`;
|
|
1005
|
+
try {
|
|
1006
|
+
const limitsResponse = await fetch(limitsUrl, { headers: { Accept: "application/json" } });
|
|
1007
|
+
const limitsBody = await limitsResponse.json();
|
|
1008
|
+
const endpoints = limitsBody.limits ? Object.values(limitsBody.limits) : [];
|
|
1009
|
+
const safeEndpoints = endpoints.filter((e) => e.endpoint && e.method !== "POST" && e.method !== "DELETE");
|
|
1010
|
+
|
|
1011
|
+
// Try to get a successful response from documented endpoints to check proactive headers.
|
|
1012
|
+
// Many endpoints need parameters, so try common safe probe values if bare requests fail.
|
|
1013
|
+
const PROBE_PARAMS = ["?id=example.com", "?url=https://example.com"];
|
|
1014
|
+
|
|
1015
|
+
let proactiveConfirmed = false;
|
|
1016
|
+
const skipped = [];
|
|
1017
|
+
|
|
1018
|
+
for (const ep of safeEndpoints) {
|
|
1019
|
+
if (proactiveConfirmed) break;
|
|
1020
|
+
// Skip path-parameterized endpoints (e.g. /results/:id) — we can't guess the value
|
|
1021
|
+
if (ep.endpoint.includes(":")) {
|
|
1022
|
+
skipped.push(`${ep.endpoint} (path parameter)`);
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const attempts = [`${options.baseUrl}${ep.endpoint}`];
|
|
1027
|
+
for (const probe of PROBE_PARAMS) {
|
|
1028
|
+
attempts.push(`${options.baseUrl}${ep.endpoint}${probe}`);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
for (const testUrl of attempts) {
|
|
1032
|
+
const display = testUrl.replace(options.baseUrl, "");
|
|
1033
|
+
console.error(` Checking proactive headers on ${display}...`);
|
|
1034
|
+
try {
|
|
1035
|
+
const testResponse = await fetch(testUrl, { headers: { Accept: "application/json" } });
|
|
1036
|
+
if (!testResponse.ok) continue;
|
|
1037
|
+
|
|
1038
|
+
const headers = {};
|
|
1039
|
+
for (const [k, v] of testResponse.headers) {
|
|
1040
|
+
headers[k] = v;
|
|
1041
|
+
}
|
|
1042
|
+
const proactiveCheck = checkProactiveHeaders(headers);
|
|
1043
|
+
report.proactiveHeaders = proactiveCheck;
|
|
1044
|
+
|
|
1045
|
+
if (proactiveCheck.hasProactiveHeaders) {
|
|
1046
|
+
report.conformanceLevel = 4;
|
|
1047
|
+
report.notes.push(
|
|
1048
|
+
`Level 4 (Proactive) confirmed: ${display} returns RateLimit headers ` +
|
|
1049
|
+
`(limit=${proactiveCheck.limitValue}, remaining=${proactiveCheck.remainingValue}, reset=${proactiveCheck.resetValue}).`
|
|
1050
|
+
);
|
|
1051
|
+
proactiveConfirmed = true;
|
|
1052
|
+
break;
|
|
1053
|
+
}
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
// try next attempt
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if (!proactiveConfirmed) {
|
|
1061
|
+
let msg = "Proactive headers (Level 4) not found on any successful endpoint response.";
|
|
1062
|
+
if (skipped.length > 0) {
|
|
1063
|
+
msg += ` Skipped: ${skipped.join(", ")}.`;
|
|
1064
|
+
}
|
|
1065
|
+
report.notes.push(msg);
|
|
1066
|
+
}
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
report.notes.push(`Could not re-fetch limits body for proactive header check: ${error.message}`);
|
|
1069
|
+
}
|
|
1070
|
+
} else if (foundLimits) {
|
|
1071
|
+
report.notes.push("Limits endpoint found but not well-formed. Level 2 not confirmed.");
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (options.checkCloaking) {
|
|
1076
|
+
try {
|
|
1077
|
+
console.error("Checking content variant containment...");
|
|
1078
|
+
report.contentCloaking = await checkContentCloaking(options.baseUrl);
|
|
1079
|
+
if (report.contentCloaking.likelyCloaking) {
|
|
1080
|
+
report.notes.push(
|
|
1081
|
+
`Possible agent-signaled content cloaking: containment ${(report.contentCloaking.containment * 100).toFixed(1)}%.`
|
|
1082
|
+
);
|
|
1083
|
+
} else {
|
|
1084
|
+
report.notes.push(
|
|
1085
|
+
`Content variant containment check passed: ${(report.contentCloaking.containment * 100).toFixed(1)}%.`
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
report.notes.push(`Could not run content cloaking check: ${error.message}`);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (options.json) {
|
|
1094
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1095
|
+
} else {
|
|
1096
|
+
console.log("");
|
|
1097
|
+
console.log(`Graceful Boundaries conformance check: ${options.baseUrl}`);
|
|
1098
|
+
console.log("=".repeat(60));
|
|
1099
|
+
console.log("");
|
|
1100
|
+
|
|
1101
|
+
console.log("Limits Discovery:");
|
|
1102
|
+
for (const r of limitsResults) {
|
|
1103
|
+
if (r.found) {
|
|
1104
|
+
console.log(` ${r.path}: FOUND (${r.limitCount} endpoints, well-formed: ${r.wellFormed}, cacheable: ${r.isCacheable})`);
|
|
1105
|
+
} else {
|
|
1106
|
+
console.log(` ${r.path}: NOT FOUND (${r.status || r.error})`);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
console.log("");
|
|
1111
|
+
const levelDisplay = report.conformanceLevel === "not-applicable"
|
|
1112
|
+
? "N/A (Not Applicable)"
|
|
1113
|
+
: `Level ${report.conformanceLevel}`;
|
|
1114
|
+
console.log(`Confirmed conformance level: ${levelDisplay}`);
|
|
1115
|
+
console.log("");
|
|
1116
|
+
|
|
1117
|
+
if (report.notes.length > 0) {
|
|
1118
|
+
console.log("Notes:");
|
|
1119
|
+
report.notes.forEach((n) => console.log(` - ${n}`));
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if (options.minLevel !== null) {
|
|
1124
|
+
const confirmed = report.conformanceLevel === "not-applicable" ? 0 : report.conformanceLevel;
|
|
1125
|
+
if (confirmed < options.minLevel) {
|
|
1126
|
+
console.error(
|
|
1127
|
+
`Confirmed level ${report.conformanceLevel} is below required minimum ${options.minLevel}. ` +
|
|
1128
|
+
"Note: Levels 1 and 3 require observing a live refusal (e.g. a 429) and cannot be confirmed passively."
|
|
1129
|
+
);
|
|
1130
|
+
process.exit(2);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
module.exports = {
|
|
1136
|
+
main,
|
|
1137
|
+
parseArgs,
|
|
1138
|
+
checkRefusalBody,
|
|
1139
|
+
checkResponseBody,
|
|
1140
|
+
checkLimitsBody,
|
|
1141
|
+
checkProactiveHeaders,
|
|
1142
|
+
checkHtmlRefusal,
|
|
1143
|
+
checkDedupResponse,
|
|
1144
|
+
checkContentContainment,
|
|
1145
|
+
containsInstructionLikeText,
|
|
1146
|
+
isStableErrorValue,
|
|
1147
|
+
isExtensionUrlSafe,
|
|
1148
|
+
isSafeSameOriginOrRelativeUrl,
|
|
1149
|
+
checkExtensions,
|
|
1150
|
+
checkActionBoundariesBody,
|
|
1151
|
+
assessLevel,
|
|
1152
|
+
REQUIRED_REFUSAL_FIELDS,
|
|
1153
|
+
REQUIRED_RESPONSE_FIELDS,
|
|
1154
|
+
CONSTRUCTIVE_FIELDS,
|
|
1155
|
+
VALID_CONFORMANCE_VALUES,
|
|
1156
|
+
REQUIRED_LIMIT_ENTRY_FIELDS,
|
|
1157
|
+
VALID_ACTION_BOUNDARY_PROFILES,
|
|
1158
|
+
VALID_ACTION_STATUSES,
|
|
1159
|
+
VALID_ACTION_AUTHORITY_VALUES,
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
if (require.main === module) {
|
|
1163
|
+
main().catch((error) => {
|
|
1164
|
+
console.error(error);
|
|
1165
|
+
process.exit(1);
|
|
1166
|
+
});
|
|
1167
|
+
}
|