@nekzus/liop 2.1.0-alpha.6 → 2.1.0-alpha.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/agent.js +3 -1
- package/dist/bin/agent.js.map +1 -1
- package/dist/bridge.js +2 -1
- package/dist/chunk-GDITQCKD.js +10466 -0
- package/dist/chunk-GDITQCKD.js.map +1 -0
- package/dist/chunk-HGTKLHOL.js +4045 -0
- package/dist/chunk-HGTKLHOL.js.map +1 -0
- package/dist/{chunk-AL7H7DTW.js → chunk-PYIGZG6E.js} +3 -3
- package/dist/{chunk-AL7H7DTW.js.map → chunk-PYIGZG6E.js.map} +1 -1
- package/dist/chunk-PZ5AY32C.js +9 -0
- package/dist/chunk-PZ5AY32C.js.map +1 -0
- package/dist/chunk-R5NHKIC3.js +30 -0
- package/dist/chunk-R5NHKIC3.js.map +1 -0
- package/dist/client.js +1 -0
- package/dist/gateway.js +1 -0
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/kyber-FCVPX6CE.js +4 -0
- package/dist/{kyber-3ULIJSE3.js.map → kyber-FCVPX6CE.js.map} +1 -1
- package/dist/mesh.js +1 -0
- package/dist/server.js +3 -1
- package/dist/types.js +3 -1
- package/dist/verifier-GCZDNZK7.js +6 -0
- package/dist/{verifier-3FAKCFNN.js.map → verifier-GCZDNZK7.js.map} +1 -1
- package/dist/workers/logic-execution.js +1 -0
- package/dist/workers/logic-execution.js.map +1 -1
- package/dist/workers/zk-verifier.js +1 -0
- package/dist/workers/zk-verifier.js.map +1 -1
- package/package.json +7 -7
- package/dist/chunk-4KIGYPIQ.js +0 -3298
- package/dist/chunk-4KIGYPIQ.js.map +0 -1
- package/dist/chunk-CT6NHSYP.js +0 -30
- package/dist/chunk-CT6NHSYP.js.map +0 -1
- package/dist/kyber-3ULIJSE3.js +0 -3
- package/dist/verifier-3FAKCFNN.js +0 -5
package/dist/chunk-4KIGYPIQ.js
DELETED
|
@@ -1,3298 +0,0 @@
|
|
|
1
|
-
import { LIOP_SCOPES, authorizeRequest } from './chunk-IJHTRIZC.js';
|
|
2
|
-
import { liopV1, createServerCredentials } from './chunk-RDWCGZ2A.js';
|
|
3
|
-
import { MeshNode } from './chunk-MMYZR7G7.js';
|
|
4
|
-
import { log } from './chunk-72MNYFR6.js';
|
|
5
|
-
import { Buffer } from 'buffer';
|
|
6
|
-
import crypto2 from 'crypto';
|
|
7
|
-
import * as fs from 'fs';
|
|
8
|
-
import { createRequire } from 'module';
|
|
9
|
-
import path from 'path';
|
|
10
|
-
import { fileURLToPath, pathToFileURL } from 'url';
|
|
11
|
-
import * as grpc2 from '@grpc/grpc-js';
|
|
12
|
-
import { createMlKem768 } from 'mlkem';
|
|
13
|
-
import { Piscina, FixedQueue } from 'piscina';
|
|
14
|
-
import { z } from 'zod';
|
|
15
|
-
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
16
|
-
import * as jose from 'jose';
|
|
17
|
-
import Provider from 'oidc-provider';
|
|
18
|
-
import * as acorn from 'acorn';
|
|
19
|
-
import { simple } from 'acorn-walk';
|
|
20
|
-
|
|
21
|
-
var GRPC_CHANNEL_OPTIONS = {
|
|
22
|
-
"grpc.keepalive_time_ms": 3e4,
|
|
23
|
-
"grpc.keepalive_timeout_ms": 1e4,
|
|
24
|
-
"grpc.keepalive_permit_without_calls": 1,
|
|
25
|
-
"grpc.max_send_message_length": -1,
|
|
26
|
-
"grpc.max_receive_message_length": -1,
|
|
27
|
-
"grpc.enable_retries": 1
|
|
28
|
-
};
|
|
29
|
-
var LiopRpcServer = class {
|
|
30
|
-
server;
|
|
31
|
-
constructor() {
|
|
32
|
-
this.server = new grpc2.Server(GRPC_CHANNEL_OPTIONS);
|
|
33
|
-
}
|
|
34
|
-
addService(handlers) {
|
|
35
|
-
this.server.addService(liopV1.LogicMesh.service, {
|
|
36
|
-
NegotiateIntent: handlers.negotiateIntent,
|
|
37
|
-
ExecuteLogic: handlers.executeLogic
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
async listen(port = 50051, tls) {
|
|
41
|
-
const credentials = createServerCredentials(tls);
|
|
42
|
-
return new Promise((resolve, reject) => {
|
|
43
|
-
this.server.bindAsync(
|
|
44
|
-
`0.0.0.0:${port}`,
|
|
45
|
-
credentials,
|
|
46
|
-
(error, assignedPort) => {
|
|
47
|
-
if (error) {
|
|
48
|
-
reject(error);
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
log.info(`[LIOP-RPC] Server listening on port ${assignedPort}`);
|
|
52
|
-
resolve(assignedPort);
|
|
53
|
-
}
|
|
54
|
-
);
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
async stop() {
|
|
58
|
-
return new Promise((resolve) => {
|
|
59
|
-
this.server.tryShutdown(() => {
|
|
60
|
-
log.info("[LIOP-RPC] Server shut down");
|
|
61
|
-
resolve();
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
// src/security/auth-config.ts
|
|
68
|
-
var AUTH_DEFAULTS = {
|
|
69
|
-
/** JWT audience claim for the LIOP mesh API. */
|
|
70
|
-
audience: "urn:liop:mesh:api",
|
|
71
|
-
/** M2M token time-to-live in seconds (1 hour). */
|
|
72
|
-
tokenTtlSeconds: 3600,
|
|
73
|
-
/** JWKS cache TTL in milliseconds (10 min — jose default). */
|
|
74
|
-
jwksCacheTtlMs: 6e5,
|
|
75
|
-
/** Minimum interval between JWKS refetches (30s — jose default). */
|
|
76
|
-
jwksCooldownMs: 3e4,
|
|
77
|
-
/** Clock tolerance for JWT expiration checks (mesh clock skew). */
|
|
78
|
-
clockToleranceSec: 5,
|
|
79
|
-
/** JWT signing algorithm (aligned with libp2p Ed25519 PeerID curve). */
|
|
80
|
-
signingAlgorithm: "EdDSA"
|
|
81
|
-
};
|
|
82
|
-
var JwtValidator = class {
|
|
83
|
-
jwksResolver;
|
|
84
|
-
issuer;
|
|
85
|
-
audience;
|
|
86
|
-
constructor(issuer, audience, jwksSource) {
|
|
87
|
-
this.issuer = issuer;
|
|
88
|
-
this.audience = audience;
|
|
89
|
-
if (jwksSource instanceof URL) {
|
|
90
|
-
this.jwksResolver = jose.createRemoteJWKSet(jwksSource, {
|
|
91
|
-
cacheMaxAge: AUTH_DEFAULTS.jwksCacheTtlMs,
|
|
92
|
-
cooldownDuration: AUTH_DEFAULTS.jwksCooldownMs
|
|
93
|
-
});
|
|
94
|
-
} else {
|
|
95
|
-
this.jwksResolver = jose.createLocalJWKSet(jwksSource);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Validates a JWT access token and extracts authorization context.
|
|
100
|
-
*
|
|
101
|
-
* Checks performed (jose.jwtVerify):
|
|
102
|
-
* - Cryptographic signature verification (EdDSA or ES256)
|
|
103
|
-
* - Issuer claim matches configured issuer
|
|
104
|
-
* - Audience claim matches configured audience
|
|
105
|
-
* - Token is not expired (with clock tolerance for mesh skew)
|
|
106
|
-
* - Required claims (sub, scope) are present
|
|
107
|
-
*
|
|
108
|
-
* @throws JOSEError if validation fails (expired, wrong issuer, bad signature, etc.)
|
|
109
|
-
*/
|
|
110
|
-
async validate(token) {
|
|
111
|
-
const issuers = this.buildIssuerAliases();
|
|
112
|
-
const { payload } = await jose.jwtVerify(token, this.jwksResolver, {
|
|
113
|
-
issuer: issuers.length > 1 ? issuers : this.issuer,
|
|
114
|
-
audience: this.audience,
|
|
115
|
-
// Algorithm whitelist: only accept EdDSA (Ed25519) or ES256.
|
|
116
|
-
// Prevents algorithm confusion attacks (OWASP API-A01).
|
|
117
|
-
algorithms: [AUTH_DEFAULTS.signingAlgorithm, "ES256"],
|
|
118
|
-
// Clock tolerance for P2P mesh nodes with slight time drift.
|
|
119
|
-
clockTolerance: AUTH_DEFAULTS.clockToleranceSec,
|
|
120
|
-
// Enforce required claims to prevent malformed tokens.
|
|
121
|
-
requiredClaims: ["sub", "scope"]
|
|
122
|
-
});
|
|
123
|
-
return {
|
|
124
|
-
token,
|
|
125
|
-
clientId: payload.sub ?? "unknown",
|
|
126
|
-
scopes: typeof payload.scope === "string" ? payload.scope.split(" ") : [],
|
|
127
|
-
expiresAt: payload.exp
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
/**
|
|
131
|
-
* Builds a complete set of issuer URL aliases for the LIOP demo topology.
|
|
132
|
-
*
|
|
133
|
-
* The LIOP mesh runs on Docker Desktop with the following network layout:
|
|
134
|
-
* Container hostnames: "nexus", "liop-nexus" (internal port 3000)
|
|
135
|
-
* Host-published ports: 13000 (HTTP/MCP), 13001 (libp2p TCP)
|
|
136
|
-
* Loopback addresses: 127.0.0.1, localhost
|
|
137
|
-
*
|
|
138
|
-
* The Nexus OAuth server may set its issuer to any of these depending on
|
|
139
|
-
* how it was configured, and nodes may resolve it via LIOP_NEXUS_URL
|
|
140
|
-
* which varies by context. This method generates all equivalent aliases
|
|
141
|
-
* so that jose.jwtVerify accepts the token regardless of which alias
|
|
142
|
-
* was used as `iss` in the JWT.
|
|
143
|
-
*
|
|
144
|
-
* Security note: This does NOT weaken validation — the cryptographic
|
|
145
|
-
* signature is still verified against the same JWKS keys. Only the
|
|
146
|
-
* string comparison of the `iss` claim is relaxed across known aliases.
|
|
147
|
-
*/
|
|
148
|
-
buildIssuerAliases() {
|
|
149
|
-
const issuers = [this.issuer];
|
|
150
|
-
const cleanIssuer = this.issuer.endsWith("/") ? this.issuer.slice(0, -1) : this.issuer;
|
|
151
|
-
const NEXUS_AUTHORITIES = [
|
|
152
|
-
"nexus:3000",
|
|
153
|
-
"liop-nexus:3000",
|
|
154
|
-
"127.0.0.1:3000",
|
|
155
|
-
"localhost:3000",
|
|
156
|
-
"127.0.0.1:13000",
|
|
157
|
-
"localhost:13000",
|
|
158
|
-
"127.0.0.1:13001",
|
|
159
|
-
"localhost:13001"
|
|
160
|
-
];
|
|
161
|
-
let issuerAuthority = "";
|
|
162
|
-
try {
|
|
163
|
-
const url = new URL(cleanIssuer);
|
|
164
|
-
issuerAuthority = `${url.hostname}:${url.port || "3000"}`;
|
|
165
|
-
} catch {
|
|
166
|
-
return issuers;
|
|
167
|
-
}
|
|
168
|
-
const isNexusIssuer = NEXUS_AUTHORITIES.some(
|
|
169
|
-
(authority) => issuerAuthority === authority
|
|
170
|
-
);
|
|
171
|
-
if (!isNexusIssuer) return issuers;
|
|
172
|
-
const pathSuffix = cleanIssuer.replace(/^https?:\/\/[^/]+/, "");
|
|
173
|
-
for (const authority of NEXUS_AUTHORITIES) {
|
|
174
|
-
const alias = `http://${authority}${pathSuffix}`;
|
|
175
|
-
if (!issuers.includes(alias)) {
|
|
176
|
-
issuers.push(alias);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
if (pathSuffix) {
|
|
180
|
-
for (const authority of NEXUS_AUTHORITIES) {
|
|
181
|
-
const alias = `http://${authority}`;
|
|
182
|
-
if (!issuers.includes(alias)) {
|
|
183
|
-
issuers.push(alias);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
} else {
|
|
187
|
-
for (const authority of NEXUS_AUTHORITIES) {
|
|
188
|
-
const alias = `http://${authority}/oidc`;
|
|
189
|
-
if (!issuers.includes(alias)) {
|
|
190
|
-
issuers.push(alias);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
return issuers;
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* Returns the configured issuer URL for PRM (RFC 9728) metadata.
|
|
198
|
-
*/
|
|
199
|
-
getIssuer() {
|
|
200
|
-
return this.issuer;
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* Returns the configured audience for PRM metadata.
|
|
204
|
-
*/
|
|
205
|
-
getAudience() {
|
|
206
|
-
return this.audience;
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
function createOAuthServer(config) {
|
|
210
|
-
const { privateKey, publicKey } = crypto2.generateKeyPairSync("ed25519");
|
|
211
|
-
const privateJwk = privateKey.export({ format: "jwk" });
|
|
212
|
-
const publicJwk = publicKey.export({ format: "jwk" });
|
|
213
|
-
const kid = crypto2.createHash("sha256").update(publicJwk.x || "").digest("hex").slice(0, 16);
|
|
214
|
-
const privateJwkComplete = {
|
|
215
|
-
...privateJwk,
|
|
216
|
-
kid,
|
|
217
|
-
use: "sig",
|
|
218
|
-
alg: "EdDSA"
|
|
219
|
-
};
|
|
220
|
-
const publicJwkComplete = {
|
|
221
|
-
...publicJwk,
|
|
222
|
-
kid,
|
|
223
|
-
use: "sig",
|
|
224
|
-
alg: "EdDSA"
|
|
225
|
-
};
|
|
226
|
-
const jwksInternal = {
|
|
227
|
-
keys: [privateJwkComplete]
|
|
228
|
-
};
|
|
229
|
-
const jwksPublic = {
|
|
230
|
-
keys: [publicJwkComplete]
|
|
231
|
-
};
|
|
232
|
-
const oidcConfig = {
|
|
233
|
-
// Supported scopes at the Authorization Server level (RFC 6749)
|
|
234
|
-
scopes: ["openid", "offline_access", ...LIOP_SCOPES],
|
|
235
|
-
// Define registered Machine-to-Machine clients
|
|
236
|
-
clients: config.clients.map((c) => ({
|
|
237
|
-
client_id: c.client_id,
|
|
238
|
-
client_secret: c.client_secret,
|
|
239
|
-
grant_types: ["client_credentials"],
|
|
240
|
-
response_types: [],
|
|
241
|
-
// CC Grant does not use response types
|
|
242
|
-
redirect_uris: [],
|
|
243
|
-
// M2M flows require no redirects
|
|
244
|
-
scope: c.scope,
|
|
245
|
-
token_endpoint_auth_method: "client_secret_post",
|
|
246
|
-
id_token_signed_response_alg: "EdDSA"
|
|
247
|
-
})),
|
|
248
|
-
// [SEC] Features whitelist: Enable Client Credentials, disable all human interactions
|
|
249
|
-
features: {
|
|
250
|
-
clientCredentials: { enabled: true },
|
|
251
|
-
devInteractions: { enabled: false },
|
|
252
|
-
// Prevent development interactions UI
|
|
253
|
-
resourceIndicators: {
|
|
254
|
-
enabled: true,
|
|
255
|
-
useGrantedResource: () => true,
|
|
256
|
-
getResourceServerInfo: (_ctx, _resource, client) => {
|
|
257
|
-
return {
|
|
258
|
-
scope: client.scope || "",
|
|
259
|
-
accessTokenFormat: "jwt",
|
|
260
|
-
jwt: {
|
|
261
|
-
sign: { alg: "EdDSA" }
|
|
262
|
-
}
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
},
|
|
267
|
-
// [SEC] Emit M2M Access Tokens as JWTs instead of opaque strings
|
|
268
|
-
// This enables high-performance local validation at resource servers (NIST SP 800-207)
|
|
269
|
-
formats: {
|
|
270
|
-
AccessToken: "jwt"
|
|
271
|
-
// biome-ignore lint/suspicious/noExplicitAny: library typings mismatch in oidc-provider
|
|
272
|
-
},
|
|
273
|
-
// [SEC] Lockout: Throw immediate error on any interactive login flow attempt
|
|
274
|
-
interactions: {
|
|
275
|
-
url: () => {
|
|
276
|
-
throw new Error(
|
|
277
|
-
"InteractionsNotSupportedException: This Authorization Server is strictly configured for Machine-to-Machine flows."
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
},
|
|
281
|
-
// Keys used for signing Issued Tokens (ID Tokens, Access Tokens)
|
|
282
|
-
jwks: jwksInternal,
|
|
283
|
-
// Token life configurations (NIST SP 800-63B token rotation guidelines)
|
|
284
|
-
ttl: {
|
|
285
|
-
ClientCredentials: 3600
|
|
286
|
-
// Access Token valid for 1 hour
|
|
287
|
-
},
|
|
288
|
-
// Allow HTTP locally for development/Docker testnets (production MUST use HTTPS in proxy)
|
|
289
|
-
cookies: {
|
|
290
|
-
keys: [crypto2.randomBytes(32).toString("hex")]
|
|
291
|
-
},
|
|
292
|
-
// Map scopes directly into the JWT token claims during client_credentials grant
|
|
293
|
-
extraTokenClaims: (_ctx, token) => {
|
|
294
|
-
if (token.kind === "AccessToken") {
|
|
295
|
-
return {
|
|
296
|
-
scope: token.scope
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
return {};
|
|
300
|
-
}
|
|
301
|
-
};
|
|
302
|
-
const normalizedIssuer = config.issuer.endsWith("/") ? config.issuer.slice(0, -1) : config.issuer;
|
|
303
|
-
const provider = new Provider(normalizedIssuer, oidcConfig);
|
|
304
|
-
provider.proxy = true;
|
|
305
|
-
return {
|
|
306
|
-
provider,
|
|
307
|
-
jwks: jwksPublic
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
var TaintAnalyzer = class _TaintAnalyzer {
|
|
311
|
-
piiFields;
|
|
312
|
-
sensitiveKeys;
|
|
313
|
-
/** String methods that extract character-level information from PII */
|
|
314
|
-
static TAINT_PROPAGATING_METHODS = /* @__PURE__ */ new Set([
|
|
315
|
-
// Character extraction
|
|
316
|
-
"charCodeAt",
|
|
317
|
-
"codePointAt",
|
|
318
|
-
"charAt",
|
|
319
|
-
"at",
|
|
320
|
-
// Search/position (reveals content structure)
|
|
321
|
-
"indexOf",
|
|
322
|
-
"lastIndexOf",
|
|
323
|
-
"search",
|
|
324
|
-
// Comparison (reveals ordering/content)
|
|
325
|
-
"localeCompare",
|
|
326
|
-
"startsWith",
|
|
327
|
-
"endsWith",
|
|
328
|
-
"includes",
|
|
329
|
-
// Transformation (preserves PII content in different form)
|
|
330
|
-
"substring",
|
|
331
|
-
"slice",
|
|
332
|
-
"substr",
|
|
333
|
-
"split",
|
|
334
|
-
"match",
|
|
335
|
-
"matchAll",
|
|
336
|
-
"replace",
|
|
337
|
-
"replaceAll",
|
|
338
|
-
"normalize",
|
|
339
|
-
"toLowerCase",
|
|
340
|
-
"toUpperCase",
|
|
341
|
-
"trim",
|
|
342
|
-
"trimStart",
|
|
343
|
-
"trimEnd",
|
|
344
|
-
"padStart",
|
|
345
|
-
"padEnd",
|
|
346
|
-
"repeat"
|
|
347
|
-
]);
|
|
348
|
-
/** Array iteration methods whose callbacks receive individual records */
|
|
349
|
-
static ARRAY_CALLBACK_METHODS = /* @__PURE__ */ new Set([
|
|
350
|
-
"map",
|
|
351
|
-
"forEach",
|
|
352
|
-
"filter",
|
|
353
|
-
"find",
|
|
354
|
-
"some",
|
|
355
|
-
"every",
|
|
356
|
-
"flatMap",
|
|
357
|
-
"findIndex"
|
|
358
|
-
]);
|
|
359
|
-
/** Reduce-family methods where the record param is the SECOND callback arg */
|
|
360
|
-
static REDUCE_METHODS = /* @__PURE__ */ new Set(["reduce", "reduceRight"]);
|
|
361
|
-
constructor(piiFields, sensitiveKeys = []) {
|
|
362
|
-
this.piiFields = new Set(piiFields.map((f) => f.toLowerCase()));
|
|
363
|
-
this.sensitiveKeys = new Set(sensitiveKeys.map((f) => f.toLowerCase()));
|
|
364
|
-
}
|
|
365
|
-
/**
|
|
366
|
-
* Classifies a field into forbidden, sensitive, or public tiers (NIST SP 800-226).
|
|
367
|
-
*/
|
|
368
|
-
classifyField(field, policySensitiveKeys = []) {
|
|
369
|
-
const lowerField = field.toLowerCase();
|
|
370
|
-
if (this.piiFields.has(lowerField)) {
|
|
371
|
-
return "forbidden";
|
|
372
|
-
}
|
|
373
|
-
const lowerPolicyKeys = new Set(
|
|
374
|
-
policySensitiveKeys.map((k) => k.toLowerCase())
|
|
375
|
-
);
|
|
376
|
-
if (this.sensitiveKeys.has(lowerField) || lowerPolicyKeys.has(lowerField)) {
|
|
377
|
-
return "sensitive";
|
|
378
|
-
}
|
|
379
|
-
return "public";
|
|
380
|
-
}
|
|
381
|
-
/**
|
|
382
|
-
* Analyzes injected source code for PII taint violations.
|
|
383
|
-
*
|
|
384
|
-
* @param sourceCode - The raw JavaScript logic extracted from the LIOP envelope
|
|
385
|
-
* @param recordCount - Size of source dataset (enables correlation/min-max gates for small sets)
|
|
386
|
-
* @param minMaxBlockThreshold - Threshold below which extrema/correlation extraction is blocked
|
|
387
|
-
* @returns A TaintViolation if PII-derived values flow to output, null if clean
|
|
388
|
-
*/
|
|
389
|
-
analyze(sourceCode, recordCount, minMaxBlockThreshold = 50) {
|
|
390
|
-
let ast;
|
|
391
|
-
try {
|
|
392
|
-
const wrapped = `function liop_analysis_wrapper(env) {
|
|
393
|
-
${sourceCode}
|
|
394
|
-
}`;
|
|
395
|
-
ast = acorn.parse(wrapped, {
|
|
396
|
-
ecmaVersion: 2022,
|
|
397
|
-
sourceType: "script",
|
|
398
|
-
locations: true
|
|
399
|
-
});
|
|
400
|
-
} catch {
|
|
401
|
-
return null;
|
|
402
|
-
}
|
|
403
|
-
const recordBoundVars = /* @__PURE__ */ new Set();
|
|
404
|
-
const taintedVars = /* @__PURE__ */ new Set();
|
|
405
|
-
this.identifyRecordBoundVars(ast, recordBoundVars);
|
|
406
|
-
this.propagateTaint(ast, recordBoundVars, taintedVars);
|
|
407
|
-
const taintResult = this.checkReturnStatements(
|
|
408
|
-
ast,
|
|
409
|
-
recordBoundVars,
|
|
410
|
-
taintedVars
|
|
411
|
-
);
|
|
412
|
-
if (taintResult) return taintResult;
|
|
413
|
-
if (recordCount !== void 0 && recordCount > 0 && recordCount < minMaxBlockThreshold) {
|
|
414
|
-
const correlationResult = this.detectCorrelatedAggregations(ast);
|
|
415
|
-
if (correlationResult) {
|
|
416
|
-
correlationResult.reason = correlationResult.reason.replace(
|
|
417
|
-
"50 records",
|
|
418
|
-
`${minMaxBlockThreshold} records`
|
|
419
|
-
);
|
|
420
|
-
return correlationResult;
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
if (recordCount !== void 0 && recordCount > 0 && recordCount < minMaxBlockThreshold) {
|
|
424
|
-
const minMaxResult = this.detectMinMaxExtraction(ast);
|
|
425
|
-
if (minMaxResult) {
|
|
426
|
-
minMaxResult.reason = minMaxResult.reason.replace(
|
|
427
|
-
"50 records",
|
|
428
|
-
`${minMaxBlockThreshold} records`
|
|
429
|
-
);
|
|
430
|
-
return minMaxResult;
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
return null;
|
|
434
|
-
}
|
|
435
|
-
/**
|
|
436
|
-
* Extracts all unique field names accessed via env.records operations.
|
|
437
|
-
* Used by the Query Budget to enforce per-field query limits.
|
|
438
|
-
*/
|
|
439
|
-
extractQueriedFields(sourceCode) {
|
|
440
|
-
let ast;
|
|
441
|
-
try {
|
|
442
|
-
ast = acorn.parse(`function w(env) {
|
|
443
|
-
${sourceCode}
|
|
444
|
-
}`, {
|
|
445
|
-
ecmaVersion: 2022,
|
|
446
|
-
sourceType: "script"
|
|
447
|
-
});
|
|
448
|
-
} catch {
|
|
449
|
-
return [];
|
|
450
|
-
}
|
|
451
|
-
const recordBoundVars = /* @__PURE__ */ new Set();
|
|
452
|
-
this.identifyRecordBoundVars(ast, recordBoundVars);
|
|
453
|
-
const fields = /* @__PURE__ */ new Set();
|
|
454
|
-
const visitors = {
|
|
455
|
-
MemberExpression: (node) => {
|
|
456
|
-
const propName = this.getPropertyName(node);
|
|
457
|
-
if (!propName || propName === "length") return;
|
|
458
|
-
if (node.object.type === "Identifier" && recordBoundVars.has(node.object.name)) {
|
|
459
|
-
fields.add(propName);
|
|
460
|
-
}
|
|
461
|
-
if (node.object.type === "MemberExpression") {
|
|
462
|
-
const parentMember = node.object;
|
|
463
|
-
if (parentMember.computed && this.isEnvRecordsAccess(parentMember.object)) {
|
|
464
|
-
fields.add(propName);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
};
|
|
469
|
-
simple(ast, visitors);
|
|
470
|
-
return Array.from(fields);
|
|
471
|
-
}
|
|
472
|
-
// ── Pass 4: Correlation Guard ─────────────────────────────────────
|
|
473
|
-
/**
|
|
474
|
-
* Detects when 2+ reduce/aggregation calls access the same field.
|
|
475
|
-
* This prevents differencing attacks: sum(all.field) - sum(excl1.field) = individual value.
|
|
476
|
-
* Exempt: .length access (metadata, not field access).
|
|
477
|
-
*/
|
|
478
|
-
detectCorrelatedAggregations(ast) {
|
|
479
|
-
const fieldAggCounts = /* @__PURE__ */ new Map();
|
|
480
|
-
const visitors = {
|
|
481
|
-
CallExpression: (node) => {
|
|
482
|
-
if (node.callee.type !== "MemberExpression") return;
|
|
483
|
-
const callee = node.callee;
|
|
484
|
-
const methodName = this.getPropertyName(callee);
|
|
485
|
-
if (!methodName || !_TaintAnalyzer.REDUCE_METHODS.has(methodName))
|
|
486
|
-
return;
|
|
487
|
-
if (!this.isEnvRecordsChain(callee.object)) return;
|
|
488
|
-
const callback = node.arguments[0];
|
|
489
|
-
if (!callback || callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression")
|
|
490
|
-
return;
|
|
491
|
-
const fn = callback;
|
|
492
|
-
const recordParam = fn.params.length > 1 ? fn.params[1] : fn.params[0];
|
|
493
|
-
if (!recordParam || recordParam.type !== "Identifier") return;
|
|
494
|
-
const paramName = recordParam.name;
|
|
495
|
-
const fields = this.extractFieldsFromBody(
|
|
496
|
-
fn.body,
|
|
497
|
-
paramName
|
|
498
|
-
);
|
|
499
|
-
for (const field of fields) {
|
|
500
|
-
const current = fieldAggCounts.get(field) ?? 0;
|
|
501
|
-
fieldAggCounts.set(field, current + 1);
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
};
|
|
505
|
-
simple(ast, visitors);
|
|
506
|
-
for (const [field, count] of fieldAggCounts) {
|
|
507
|
-
if (count >= 2) {
|
|
508
|
-
return {
|
|
509
|
-
reason: `Correlation guard: ${count} aggregations detected on field '${field}'. Multiple correlated aggregations on the same field can enable differencing attacks. Use a single aggregation per numeric field, or increase dataset size above 50 records.`
|
|
510
|
-
};
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
return null;
|
|
514
|
-
}
|
|
515
|
-
/**
|
|
516
|
-
* Checks if a node is env.records or a chain like env.records.slice(N) / env.records.filter(...)
|
|
517
|
-
*/
|
|
518
|
-
isEnvRecordsChain(node) {
|
|
519
|
-
if (this.isEnvRecordsAccess(node)) return true;
|
|
520
|
-
if (node.type === "CallExpression") {
|
|
521
|
-
const call = node;
|
|
522
|
-
if (call.callee.type === "MemberExpression") {
|
|
523
|
-
const member = call.callee;
|
|
524
|
-
const method = this.getPropertyName(member);
|
|
525
|
-
if (method && (method === "slice" || method === "filter" || method === "toSorted")) {
|
|
526
|
-
return this.isEnvRecordsChain(member.object);
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
return false;
|
|
531
|
-
}
|
|
532
|
-
/**
|
|
533
|
-
* Extracts field names accessed on a record parameter within a function body.
|
|
534
|
-
* e.g., in `(s, r) => s + r.balance`, extracts "balance".
|
|
535
|
-
* Ignores .length as it's metadata, not a field access.
|
|
536
|
-
*/
|
|
537
|
-
extractFieldsFromBody(body, paramName) {
|
|
538
|
-
const fields = [];
|
|
539
|
-
const visitors = {
|
|
540
|
-
MemberExpression: (node) => {
|
|
541
|
-
if (node.object.type === "Identifier" && node.object.name === paramName) {
|
|
542
|
-
const prop = this.getPropertyName(node);
|
|
543
|
-
if (prop && prop !== "length") {
|
|
544
|
-
fields.push(prop);
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
};
|
|
549
|
-
simple(body, visitors);
|
|
550
|
-
return fields;
|
|
551
|
-
}
|
|
552
|
-
// ── Pass 5: Min/Max Gate ──────────────────────────────────────────
|
|
553
|
-
/**
|
|
554
|
-
* Detects Math.min/max and sort()[0] patterns that expose individual
|
|
555
|
-
* record values from small datasets.
|
|
556
|
-
*/
|
|
557
|
-
detectMinMaxExtraction(ast) {
|
|
558
|
-
let violation = null;
|
|
559
|
-
const visitors = {
|
|
560
|
-
CallExpression: (node) => {
|
|
561
|
-
if (violation) return;
|
|
562
|
-
if (node.callee.type === "MemberExpression") {
|
|
563
|
-
const callee = node.callee;
|
|
564
|
-
if (callee.object.type === "Identifier" && callee.object.name === "Math") {
|
|
565
|
-
const method = this.getPropertyName(callee);
|
|
566
|
-
if (method === "min" || method === "max") {
|
|
567
|
-
if (node.arguments.some(
|
|
568
|
-
(arg) => arg.type === "SpreadElement" && this.isRecordsMapCall(
|
|
569
|
-
arg.argument
|
|
570
|
-
)
|
|
571
|
-
)) {
|
|
572
|
-
violation = {
|
|
573
|
-
reason: `Min/Max gate: Math.${method}() on individual records blocked for small datasets (n < 50). Use avg/stddev/count for privacy-safe aggregations.`
|
|
574
|
-
};
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
},
|
|
580
|
-
// Pattern: env.records.sort(...)[0].field or [...env.records].sort(...)[0].field
|
|
581
|
-
MemberExpression: (node) => {
|
|
582
|
-
if (violation) return;
|
|
583
|
-
if (node.computed && node.object.type === "CallExpression") {
|
|
584
|
-
const call = node.object;
|
|
585
|
-
if (call.callee.type === "MemberExpression") {
|
|
586
|
-
const method = this.getPropertyName(
|
|
587
|
-
call.callee
|
|
588
|
-
);
|
|
589
|
-
if (method === "sort" || method === "toSorted") {
|
|
590
|
-
const sortTarget = call.callee.object;
|
|
591
|
-
if (this.isEnvRecordsChain(sortTarget)) {
|
|
592
|
-
violation = {
|
|
593
|
-
reason: "Min/Max gate: .sort()[index] on individual records blocked for small datasets (n < 50). Use avg/stddev/count for privacy-safe aggregations."
|
|
594
|
-
};
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
};
|
|
601
|
-
simple(ast, visitors);
|
|
602
|
-
return violation;
|
|
603
|
-
}
|
|
604
|
-
/**
|
|
605
|
-
* Checks if a node is env.records.map(callback) — used by Min/Max Gate.
|
|
606
|
-
*/
|
|
607
|
-
isRecordsMapCall(node) {
|
|
608
|
-
if (node.type !== "CallExpression") return false;
|
|
609
|
-
const call = node;
|
|
610
|
-
if (call.callee.type !== "MemberExpression") return false;
|
|
611
|
-
const callee = call.callee;
|
|
612
|
-
const method = this.getPropertyName(callee);
|
|
613
|
-
return method === "map" && this.isEnvRecordsChain(callee.object);
|
|
614
|
-
}
|
|
615
|
-
// ── Pass 1: Record-Bound Variable Identification ──────────────────
|
|
616
|
-
identifyRecordBoundVars(ast, recordBoundVars) {
|
|
617
|
-
const visitors = {
|
|
618
|
-
CallExpression: (node) => {
|
|
619
|
-
if (node.callee.type !== "MemberExpression") return;
|
|
620
|
-
const member = node.callee;
|
|
621
|
-
const methodName = this.getPropertyName(member);
|
|
622
|
-
if (!methodName) return;
|
|
623
|
-
if (!this.isEnvRecordsAccess(member.object)) return;
|
|
624
|
-
const callback = node.arguments[0];
|
|
625
|
-
if (!callback) return;
|
|
626
|
-
if (callback.type === "ArrowFunctionExpression" || callback.type === "FunctionExpression") {
|
|
627
|
-
const fn = callback;
|
|
628
|
-
if (_TaintAnalyzer.ARRAY_CALLBACK_METHODS.has(methodName) && fn.params.length > 0) {
|
|
629
|
-
const param = fn.params[0];
|
|
630
|
-
if (param.type === "Identifier") {
|
|
631
|
-
recordBoundVars.add(param.name);
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
if (_TaintAnalyzer.REDUCE_METHODS.has(methodName) && fn.params.length > 1) {
|
|
635
|
-
const recordParam = fn.params[1];
|
|
636
|
-
if (recordParam.type === "Identifier") {
|
|
637
|
-
recordBoundVars.add(recordParam.name);
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
},
|
|
642
|
-
// for (const r of env.records) → r is record-bound
|
|
643
|
-
ForOfStatement: (node) => {
|
|
644
|
-
if (!this.isEnvRecordsAccess(node.right)) return;
|
|
645
|
-
if (node.left.type === "VariableDeclaration") {
|
|
646
|
-
for (const declarator of node.left.declarations) {
|
|
647
|
-
if (declarator.id.type === "Identifier") {
|
|
648
|
-
recordBoundVars.add(declarator.id.name);
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
};
|
|
654
|
-
simple(ast, visitors);
|
|
655
|
-
const indexVisitors = {
|
|
656
|
-
VariableDeclarator: (node) => {
|
|
657
|
-
if (!node.init || node.id.type !== "Identifier") return;
|
|
658
|
-
if (node.init.type === "MemberExpression" && node.init.computed) {
|
|
659
|
-
const member = node.init;
|
|
660
|
-
if (this.isEnvRecordsAccess(member.object)) {
|
|
661
|
-
recordBoundVars.add(node.id.name);
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
};
|
|
666
|
-
simple(ast, indexVisitors);
|
|
667
|
-
}
|
|
668
|
-
// ── Pass 2: Taint Propagation ─────────────────────────────────────
|
|
669
|
-
propagateTaint(ast, recordBoundVars, taintedVars) {
|
|
670
|
-
for (let iteration = 0; iteration < 3; iteration++) {
|
|
671
|
-
const sizeBefore = taintedVars.size;
|
|
672
|
-
const visitors = {
|
|
673
|
-
VariableDeclarator: (node) => {
|
|
674
|
-
if (!node.init || node.id.type !== "Identifier") return;
|
|
675
|
-
if (this.isExpressionTainted(node.init, recordBoundVars, taintedVars)) {
|
|
676
|
-
taintedVars.add(node.id.name);
|
|
677
|
-
}
|
|
678
|
-
},
|
|
679
|
-
AssignmentExpression: (node) => {
|
|
680
|
-
if (node.left.type !== "Identifier") return;
|
|
681
|
-
if (this.isExpressionTainted(node.right, recordBoundVars, taintedVars)) {
|
|
682
|
-
taintedVars.add(node.left.name);
|
|
683
|
-
}
|
|
684
|
-
},
|
|
685
|
-
FunctionDeclaration: (node) => {
|
|
686
|
-
if (node.id && node.id.type === "Identifier") {
|
|
687
|
-
if (this.doesCallbackProduceTaint(
|
|
688
|
-
node,
|
|
689
|
-
null,
|
|
690
|
-
recordBoundVars,
|
|
691
|
-
taintedVars
|
|
692
|
-
)) {
|
|
693
|
-
taintedVars.add(node.id.name);
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
},
|
|
697
|
-
// Imperative taint: array.push(taintedValue) contaminates the array
|
|
698
|
-
// Covers for-of and forEach patterns that push PII-derived values
|
|
699
|
-
CallExpression: (node) => {
|
|
700
|
-
if (node.callee.type !== "MemberExpression") return;
|
|
701
|
-
const callee = node.callee;
|
|
702
|
-
const methodName = this.getPropertyName(callee);
|
|
703
|
-
if (methodName === "push" && callee.object.type === "Identifier" && node.arguments.some(
|
|
704
|
-
(arg) => this.isExpressionTainted(arg, recordBoundVars, taintedVars)
|
|
705
|
-
)) {
|
|
706
|
-
taintedVars.add(callee.object.name);
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
};
|
|
710
|
-
simple(ast, visitors);
|
|
711
|
-
if (taintedVars.size === sizeBefore) break;
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
// ── Pass 3: Return Statement Sink Detection ───────────────────────
|
|
715
|
-
checkReturnStatements(ast, recordBoundVars, taintedVars) {
|
|
716
|
-
let violation = null;
|
|
717
|
-
const visitors = {
|
|
718
|
-
ReturnStatement: (node) => {
|
|
719
|
-
if (violation) return;
|
|
720
|
-
if (!node.argument) return;
|
|
721
|
-
if (this.isExpressionTainted(node.argument, recordBoundVars, taintedVars)) {
|
|
722
|
-
const line = node.loc?.start.line ? node.loc.start.line - 1 : void 0;
|
|
723
|
-
const operation = this.describeTaintSource(
|
|
724
|
-
node.argument,
|
|
725
|
-
recordBoundVars,
|
|
726
|
-
taintedVars
|
|
727
|
-
);
|
|
728
|
-
violation = {
|
|
729
|
-
reason: `PII side-channel detected: output contains values derived from restricted fields. ${operation ? `Operation: ${operation}. ` : ""}Use only non-PII fields (e.g., numeric/date columns) for aggregations.`,
|
|
730
|
-
line,
|
|
731
|
-
operation
|
|
732
|
-
};
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
};
|
|
736
|
-
simple(ast, visitors);
|
|
737
|
-
return violation;
|
|
738
|
-
}
|
|
739
|
-
// ── Core Taint Evaluation ─────────────────────────────────────────
|
|
740
|
-
/**
|
|
741
|
-
* Recursively determines if an AST expression produces a tainted value.
|
|
742
|
-
* A value is tainted if it derives from a PII field on a record-bound variable.
|
|
743
|
-
*/
|
|
744
|
-
isExpressionTainted(node, recordBoundVars, taintedVars) {
|
|
745
|
-
switch (node.type) {
|
|
746
|
-
case "Identifier":
|
|
747
|
-
return taintedVars.has(node.name);
|
|
748
|
-
case "MemberExpression":
|
|
749
|
-
return this.isMemberExprTainted(
|
|
750
|
-
node,
|
|
751
|
-
recordBoundVars,
|
|
752
|
-
taintedVars
|
|
753
|
-
);
|
|
754
|
-
case "CallExpression":
|
|
755
|
-
return this.isCallExprTainted(
|
|
756
|
-
node,
|
|
757
|
-
recordBoundVars,
|
|
758
|
-
taintedVars
|
|
759
|
-
);
|
|
760
|
-
case "YieldExpression": {
|
|
761
|
-
const yieldNode = node;
|
|
762
|
-
return yieldNode.argument ? this.isExpressionTainted(
|
|
763
|
-
yieldNode.argument,
|
|
764
|
-
recordBoundVars,
|
|
765
|
-
taintedVars
|
|
766
|
-
) : false;
|
|
767
|
-
}
|
|
768
|
-
case "FunctionExpression":
|
|
769
|
-
case "ArrowFunctionExpression": {
|
|
770
|
-
const fn = node;
|
|
771
|
-
return this.doesCallbackProduceTaint(
|
|
772
|
-
fn,
|
|
773
|
-
null,
|
|
774
|
-
recordBoundVars,
|
|
775
|
-
taintedVars
|
|
776
|
-
);
|
|
777
|
-
}
|
|
778
|
-
case "BinaryExpression":
|
|
779
|
-
case "LogicalExpression": {
|
|
780
|
-
const bin = node;
|
|
781
|
-
return this.isExpressionTainted(bin.left, recordBoundVars, taintedVars) || this.isExpressionTainted(bin.right, recordBoundVars, taintedVars);
|
|
782
|
-
}
|
|
783
|
-
case "UnaryExpression": {
|
|
784
|
-
const unary = node;
|
|
785
|
-
return this.isExpressionTainted(
|
|
786
|
-
unary.argument,
|
|
787
|
-
recordBoundVars,
|
|
788
|
-
taintedVars
|
|
789
|
-
);
|
|
790
|
-
}
|
|
791
|
-
case "ConditionalExpression": {
|
|
792
|
-
const cond = node;
|
|
793
|
-
return this.isExpressionTainted(cond.test, recordBoundVars, taintedVars) || this.isExpressionTainted(
|
|
794
|
-
cond.consequent,
|
|
795
|
-
recordBoundVars,
|
|
796
|
-
taintedVars
|
|
797
|
-
) || this.isExpressionTainted(cond.alternate, recordBoundVars, taintedVars);
|
|
798
|
-
}
|
|
799
|
-
case "ObjectExpression": {
|
|
800
|
-
const obj = node;
|
|
801
|
-
return obj.properties.some(
|
|
802
|
-
(prop) => prop.type === "Property" && this.isExpressionTainted(prop.value, recordBoundVars, taintedVars)
|
|
803
|
-
);
|
|
804
|
-
}
|
|
805
|
-
case "ArrayExpression": {
|
|
806
|
-
const arr = node;
|
|
807
|
-
return arr.elements.some(
|
|
808
|
-
(el) => el !== null && this.isExpressionTainted(el, recordBoundVars, taintedVars)
|
|
809
|
-
);
|
|
810
|
-
}
|
|
811
|
-
case "TemplateLiteral": {
|
|
812
|
-
const tmpl = node;
|
|
813
|
-
return tmpl.expressions.some(
|
|
814
|
-
(expr) => this.isExpressionTainted(expr, recordBoundVars, taintedVars)
|
|
815
|
-
);
|
|
816
|
-
}
|
|
817
|
-
case "SpreadElement": {
|
|
818
|
-
const spread = node;
|
|
819
|
-
return this.isExpressionTainted(
|
|
820
|
-
spread.argument,
|
|
821
|
-
recordBoundVars,
|
|
822
|
-
taintedVars
|
|
823
|
-
);
|
|
824
|
-
}
|
|
825
|
-
default:
|
|
826
|
-
return false;
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
/**
|
|
830
|
-
* Checks if a MemberExpression accesses a PII field on a record-bound variable.
|
|
831
|
-
* Examples: r.accountHolder, r["name"], taintedVar.length, taintedVar[0]
|
|
832
|
-
*/
|
|
833
|
-
isMemberExprTainted(member, recordBoundVars, taintedVars) {
|
|
834
|
-
const propName = this.getPropertyName(member);
|
|
835
|
-
if (member.object.type === "Identifier" && recordBoundVars.has(member.object.name) && propName && this.piiFields.has(propName.toLowerCase())) {
|
|
836
|
-
return true;
|
|
837
|
-
}
|
|
838
|
-
if (member.object.type === "MemberExpression" && propName && this.piiFields.has(propName.toLowerCase())) {
|
|
839
|
-
const parentMember = member.object;
|
|
840
|
-
if (parentMember.computed && this.isEnvRecordsAccess(parentMember.object)) {
|
|
841
|
-
return true;
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
if (this.isExpressionTainted(member.object, recordBoundVars, taintedVars)) {
|
|
845
|
-
return true;
|
|
846
|
-
}
|
|
847
|
-
if (member.computed && member.object.type === "Identifier" && recordBoundVars.has(member.object.name)) {
|
|
848
|
-
if (member.property.type === "Literal") {
|
|
849
|
-
const litVal = member.property.value;
|
|
850
|
-
if (typeof litVal === "string" && this.piiFields.has(litVal.toLowerCase())) {
|
|
851
|
-
return true;
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
return false;
|
|
856
|
-
}
|
|
857
|
-
/**
|
|
858
|
-
* Checks if a CallExpression produces a tainted result.
|
|
859
|
-
* Handles: taintedObj.method(), env.records.map(r => r.piiField), etc.
|
|
860
|
-
*/
|
|
861
|
-
isCallExprTainted(call, recordBoundVars, taintedVars) {
|
|
862
|
-
if (call.callee.type === "MemberExpression") {
|
|
863
|
-
const callee = call.callee;
|
|
864
|
-
const methodName = this.getPropertyName(callee);
|
|
865
|
-
if (methodName && _TaintAnalyzer.TAINT_PROPAGATING_METHODS.has(methodName) && this.isExpressionTainted(callee.object, recordBoundVars, taintedVars)) {
|
|
866
|
-
return true;
|
|
867
|
-
}
|
|
868
|
-
if (this.isEnvRecordsAccess(callee.object) && call.arguments[0]) {
|
|
869
|
-
const callback = call.arguments[0];
|
|
870
|
-
if (callback.type === "ArrowFunctionExpression" || callback.type === "FunctionExpression") {
|
|
871
|
-
return this.doesCallbackProduceTaint(
|
|
872
|
-
callback,
|
|
873
|
-
methodName,
|
|
874
|
-
recordBoundVars,
|
|
875
|
-
taintedVars
|
|
876
|
-
);
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
if (this.isExpressionTainted(callee.object, recordBoundVars, taintedVars)) {
|
|
880
|
-
return true;
|
|
881
|
-
}
|
|
882
|
-
if (call.arguments.some(
|
|
883
|
-
(arg) => this.isExpressionTainted(arg, recordBoundVars, taintedVars)
|
|
884
|
-
)) {
|
|
885
|
-
return true;
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
if (call.callee.type === "MemberExpression") {
|
|
889
|
-
const callee = call.callee;
|
|
890
|
-
const methodName = this.getPropertyName(callee);
|
|
891
|
-
if (methodName === "push" && callee.object.type === "Identifier" && call.arguments.some(
|
|
892
|
-
(arg) => this.isExpressionTainted(arg, recordBoundVars, taintedVars)
|
|
893
|
-
)) {
|
|
894
|
-
taintedVars.add(callee.object.name);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
if (call.callee.type === "Identifier") {
|
|
898
|
-
const fnName = call.callee.name;
|
|
899
|
-
if (taintedVars.has(fnName)) {
|
|
900
|
-
return true;
|
|
901
|
-
}
|
|
902
|
-
const SAFE_GLOBALS = /* @__PURE__ */ new Set([
|
|
903
|
-
"Math",
|
|
904
|
-
"Number",
|
|
905
|
-
"parseInt",
|
|
906
|
-
"parseFloat",
|
|
907
|
-
"isNaN",
|
|
908
|
-
"isFinite"
|
|
909
|
-
]);
|
|
910
|
-
if (!SAFE_GLOBALS.has(fnName)) {
|
|
911
|
-
return call.arguments.some(
|
|
912
|
-
(arg) => this.isExpressionTainted(arg, recordBoundVars, taintedVars)
|
|
913
|
-
);
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
return false;
|
|
917
|
-
}
|
|
918
|
-
/**
|
|
919
|
-
* Checks if an array method callback produces tainted output.
|
|
920
|
-
* e.g., env.records.map(r => r.name.charCodeAt(0)) → tainted result
|
|
921
|
-
*/
|
|
922
|
-
doesCallbackProduceTaint(callback, methodName, recordBoundVars, taintedVars) {
|
|
923
|
-
const scopedRecordVars = new Set(recordBoundVars);
|
|
924
|
-
const scopedTaintedVars = new Set(taintedVars);
|
|
925
|
-
if (callback.params.length > 0) {
|
|
926
|
-
const isReduce = methodName !== null && _TaintAnalyzer.REDUCE_METHODS.has(methodName);
|
|
927
|
-
const recordParamIndex = isReduce ? 1 : 0;
|
|
928
|
-
if (callback.params.length > recordParamIndex && callback.params[recordParamIndex].type === "Identifier") {
|
|
929
|
-
scopedRecordVars.add(
|
|
930
|
-
callback.params[recordParamIndex].name
|
|
931
|
-
);
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
if (callback.type === "ArrowFunctionExpression" && callback.body.type !== "BlockStatement") {
|
|
935
|
-
return this.isExpressionTainted(
|
|
936
|
-
callback.body,
|
|
937
|
-
scopedRecordVars,
|
|
938
|
-
scopedTaintedVars
|
|
939
|
-
);
|
|
940
|
-
}
|
|
941
|
-
let hasTaintedReturnOrYield = false;
|
|
942
|
-
const returnVisitors = {
|
|
943
|
-
ReturnStatement: (node) => {
|
|
944
|
-
if (node.argument && this.isExpressionTainted(
|
|
945
|
-
node.argument,
|
|
946
|
-
scopedRecordVars,
|
|
947
|
-
scopedTaintedVars
|
|
948
|
-
)) {
|
|
949
|
-
hasTaintedReturnOrYield = true;
|
|
950
|
-
}
|
|
951
|
-
},
|
|
952
|
-
YieldExpression: (node) => {
|
|
953
|
-
const yieldNode = node;
|
|
954
|
-
if (yieldNode.argument && this.isExpressionTainted(
|
|
955
|
-
yieldNode.argument,
|
|
956
|
-
scopedRecordVars,
|
|
957
|
-
scopedTaintedVars
|
|
958
|
-
)) {
|
|
959
|
-
hasTaintedReturnOrYield = true;
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
};
|
|
963
|
-
simple(callback.body, returnVisitors);
|
|
964
|
-
return hasTaintedReturnOrYield;
|
|
965
|
-
}
|
|
966
|
-
// ── Utility Methods ───────────────────────────────────────────────
|
|
967
|
-
/** Extracts the property name from a MemberExpression (dot or bracket with string literal) */
|
|
968
|
-
getPropertyName(member) {
|
|
969
|
-
if (!member.computed && member.property.type === "Identifier") {
|
|
970
|
-
return member.property.name;
|
|
971
|
-
}
|
|
972
|
-
if (member.computed && member.property.type === "Literal") {
|
|
973
|
-
const val = member.property.value;
|
|
974
|
-
if (typeof val === "string") return val;
|
|
975
|
-
}
|
|
976
|
-
return null;
|
|
977
|
-
}
|
|
978
|
-
/** Checks if an expression resolves to `env.records` or `records` */
|
|
979
|
-
isEnvRecordsAccess(node) {
|
|
980
|
-
if (node.type === "MemberExpression") {
|
|
981
|
-
const member = node;
|
|
982
|
-
const propName = this.getPropertyName(member);
|
|
983
|
-
if (propName === "records" && member.object.type === "Identifier" && member.object.name === "env") {
|
|
984
|
-
return true;
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
if (node.type === "Identifier" && node.name === "records") {
|
|
988
|
-
return true;
|
|
989
|
-
}
|
|
990
|
-
return false;
|
|
991
|
-
}
|
|
992
|
-
/** Generates a human-readable description of the taint source for error messages */
|
|
993
|
-
describeTaintSource(node, recordBoundVars, taintedVars) {
|
|
994
|
-
if (node.type === "Identifier") {
|
|
995
|
-
const name = node.name;
|
|
996
|
-
if (taintedVars.has(name)) return `variable '${name}' is PII-derived`;
|
|
997
|
-
}
|
|
998
|
-
if (node.type === "ObjectExpression") {
|
|
999
|
-
const obj = node;
|
|
1000
|
-
for (const prop of obj.properties) {
|
|
1001
|
-
if (prop.type === "Property" && this.isExpressionTainted(prop.value, recordBoundVars, taintedVars)) {
|
|
1002
|
-
const keyName = prop.key.type === "Identifier" ? prop.key.name : "unknown";
|
|
1003
|
-
return `property '${keyName}' contains PII-derived value`;
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
if (node.type === "CallExpression") {
|
|
1008
|
-
const call = node;
|
|
1009
|
-
if (call.callee.type === "MemberExpression") {
|
|
1010
|
-
const methodName = this.getPropertyName(
|
|
1011
|
-
call.callee
|
|
1012
|
-
);
|
|
1013
|
-
if (methodName) return `result of .${methodName}() on PII data`;
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
return void 0;
|
|
1017
|
-
}
|
|
1018
|
-
};
|
|
1019
|
-
|
|
1020
|
-
// src/server/ner-scanner.ts
|
|
1021
|
-
var MEDICAL_VOCABULARY = {
|
|
1022
|
-
aspirin: "Medication",
|
|
1023
|
-
lisinopril: "Medication",
|
|
1024
|
-
metformin: "Medication",
|
|
1025
|
-
amlodipine: "Medication",
|
|
1026
|
-
atorvastatin: "Medication",
|
|
1027
|
-
omeprazole: "Medication",
|
|
1028
|
-
losartan: "Medication",
|
|
1029
|
-
simvastatin: "Medication",
|
|
1030
|
-
levothyroxine: "Medication",
|
|
1031
|
-
ibuprofen: "Medication",
|
|
1032
|
-
acetaminophen: "Medication",
|
|
1033
|
-
amoxicillin: "Medication",
|
|
1034
|
-
ciprofloxacin: "Medication",
|
|
1035
|
-
prednisone: "Medication",
|
|
1036
|
-
warfarin: "Medication",
|
|
1037
|
-
insulin: "Medication",
|
|
1038
|
-
hydrochlorothiazide: "Medication",
|
|
1039
|
-
gabapentin: "Medication",
|
|
1040
|
-
albuterol: "Medication",
|
|
1041
|
-
pantoprazole: "Medication",
|
|
1042
|
-
// Generic clinical terms
|
|
1043
|
-
hypertension: "Condition",
|
|
1044
|
-
diabetes: "Condition",
|
|
1045
|
-
bronchitis: "Condition",
|
|
1046
|
-
pneumonia: "Condition",
|
|
1047
|
-
asthma: "Condition"
|
|
1048
|
-
};
|
|
1049
|
-
var MIN_TEXT_LENGTH = 4;
|
|
1050
|
-
var NON_TEXT_PATTERN = /^[\d\s.,:;!?()[\]{}<>@#$%^&*+=|\\/"'`~_-]+$/;
|
|
1051
|
-
var NerScanner = class _NerScanner {
|
|
1052
|
-
static nlp = null;
|
|
1053
|
-
/**
|
|
1054
|
-
* Lazy loads the compromise library only when needed.
|
|
1055
|
-
*/
|
|
1056
|
-
async getNlp() {
|
|
1057
|
-
if (!_NerScanner.nlp) {
|
|
1058
|
-
const mod = await import('compromise/three');
|
|
1059
|
-
_NerScanner.nlp = mod.default || mod;
|
|
1060
|
-
_NerScanner.nlp.addWords(MEDICAL_VOCABULARY);
|
|
1061
|
-
}
|
|
1062
|
-
return _NerScanner.nlp;
|
|
1063
|
-
}
|
|
1064
|
-
/**
|
|
1065
|
-
* Scans a single string value for named entities.
|
|
1066
|
-
* Returns detected entities if the text contains recognizable PII.
|
|
1067
|
-
*/
|
|
1068
|
-
async scan(text) {
|
|
1069
|
-
if (text.length < MIN_TEXT_LENGTH || NON_TEXT_PATTERN.test(text)) {
|
|
1070
|
-
return { detected: false, entities: [] };
|
|
1071
|
-
}
|
|
1072
|
-
const nlp = await this.getNlp();
|
|
1073
|
-
const doc = nlp(text);
|
|
1074
|
-
const entities = [];
|
|
1075
|
-
const people = doc.people().out("array");
|
|
1076
|
-
for (const person of people) {
|
|
1077
|
-
const trimmed = person.trim();
|
|
1078
|
-
if (trimmed.length >= MIN_TEXT_LENGTH) {
|
|
1079
|
-
entities.push({ type: "person", text: trimmed });
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
const places = doc.places().out("array");
|
|
1083
|
-
for (const place of places) {
|
|
1084
|
-
const trimmed = place.trim();
|
|
1085
|
-
if (trimmed.length >= MIN_TEXT_LENGTH) {
|
|
1086
|
-
entities.push({ type: "place", text: trimmed });
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
const orgs = doc.organizations().out("array");
|
|
1090
|
-
for (const org of orgs) {
|
|
1091
|
-
const trimmed = org.trim();
|
|
1092
|
-
if (trimmed.length >= MIN_TEXT_LENGTH) {
|
|
1093
|
-
entities.push({ type: "organization", text: trimmed });
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
return {
|
|
1097
|
-
detected: entities.length > 0,
|
|
1098
|
-
entities
|
|
1099
|
-
};
|
|
1100
|
-
}
|
|
1101
|
-
/**
|
|
1102
|
-
* Recursively scans all string values within an object/array.
|
|
1103
|
-
* Stops at the first detection for performance (fail-fast).
|
|
1104
|
-
*/
|
|
1105
|
-
async scanDeep(input, seen = /* @__PURE__ */ new WeakSet()) {
|
|
1106
|
-
if (input === null || input === void 0) {
|
|
1107
|
-
return { detected: false, entities: [] };
|
|
1108
|
-
}
|
|
1109
|
-
if (typeof input === "string") {
|
|
1110
|
-
return this.scan(input);
|
|
1111
|
-
}
|
|
1112
|
-
if (typeof input === "object") {
|
|
1113
|
-
if (seen.has(input)) {
|
|
1114
|
-
return { detected: false, entities: [] };
|
|
1115
|
-
}
|
|
1116
|
-
seen.add(input);
|
|
1117
|
-
const values = Array.isArray(input) ? input : Object.values(input);
|
|
1118
|
-
const allEntities = [];
|
|
1119
|
-
for (const value of values) {
|
|
1120
|
-
const result = await this.scanDeep(value, seen);
|
|
1121
|
-
if (result.detected) {
|
|
1122
|
-
allEntities.push(...result.entities);
|
|
1123
|
-
if (result.entities.some((e) => e.type === "person")) {
|
|
1124
|
-
return { detected: true, entities: allEntities };
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
return {
|
|
1129
|
-
detected: allEntities.length > 0,
|
|
1130
|
-
entities: allEntities
|
|
1131
|
-
};
|
|
1132
|
-
}
|
|
1133
|
-
return { detected: false, entities: [] };
|
|
1134
|
-
}
|
|
1135
|
-
};
|
|
1136
|
-
|
|
1137
|
-
// src/server/output-sanitizer.ts
|
|
1138
|
-
var DEFAULT_CONFIG = {
|
|
1139
|
-
maxDecimalPlaces: 4,
|
|
1140
|
-
clampNonNegative: true
|
|
1141
|
-
};
|
|
1142
|
-
function sanitizeOutput(output, config) {
|
|
1143
|
-
const merged = { ...DEFAULT_CONFIG, ...config };
|
|
1144
|
-
const seen = /* @__PURE__ */ new WeakSet();
|
|
1145
|
-
function walk(node) {
|
|
1146
|
-
if (node === null || node === void 0) {
|
|
1147
|
-
return node;
|
|
1148
|
-
}
|
|
1149
|
-
if (typeof node === "number") {
|
|
1150
|
-
if (!Number.isFinite(node)) {
|
|
1151
|
-
return node;
|
|
1152
|
-
}
|
|
1153
|
-
let value = node;
|
|
1154
|
-
if (merged.clampNonNegative && value < 0) {
|
|
1155
|
-
value = 0;
|
|
1156
|
-
}
|
|
1157
|
-
const factor = 10 ** merged.maxDecimalPlaces;
|
|
1158
|
-
value = Math.round(value * factor) / factor;
|
|
1159
|
-
return value;
|
|
1160
|
-
}
|
|
1161
|
-
if (typeof node === "string" || typeof node === "boolean") {
|
|
1162
|
-
return node;
|
|
1163
|
-
}
|
|
1164
|
-
if (typeof node === "object") {
|
|
1165
|
-
if (seen.has(node)) {
|
|
1166
|
-
return node;
|
|
1167
|
-
}
|
|
1168
|
-
seen.add(node);
|
|
1169
|
-
if (Array.isArray(node)) {
|
|
1170
|
-
return node.map((item) => walk(item));
|
|
1171
|
-
}
|
|
1172
|
-
const result = {};
|
|
1173
|
-
for (const [key, val] of Object.entries(
|
|
1174
|
-
node
|
|
1175
|
-
)) {
|
|
1176
|
-
result[key] = walk(val);
|
|
1177
|
-
}
|
|
1178
|
-
return result;
|
|
1179
|
-
}
|
|
1180
|
-
return node;
|
|
1181
|
-
}
|
|
1182
|
-
return walk(output);
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
// src/server/pii.ts
|
|
1186
|
-
function isLuhnValid(cardNumber) {
|
|
1187
|
-
const digits = cardNumber.replace(/\D/g, "");
|
|
1188
|
-
if (digits.length < 13 || digits.length > 19) return false;
|
|
1189
|
-
let sum = 0;
|
|
1190
|
-
let isEven = false;
|
|
1191
|
-
for (let i = digits.length - 1; i >= 0; i--) {
|
|
1192
|
-
let digit = parseInt(digits.charAt(i), 10);
|
|
1193
|
-
if (isEven) {
|
|
1194
|
-
digit *= 2;
|
|
1195
|
-
if (digit > 9) {
|
|
1196
|
-
digit -= 9;
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
sum += digit;
|
|
1200
|
-
isEven = !isEven;
|
|
1201
|
-
}
|
|
1202
|
-
return sum % 10 === 0;
|
|
1203
|
-
}
|
|
1204
|
-
function isIbanValid(iban) {
|
|
1205
|
-
const sanitized = iban.replace(/\s+/g, "").toUpperCase();
|
|
1206
|
-
if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]{1,30}$/.test(sanitized)) return false;
|
|
1207
|
-
const rearranged = sanitized.substring(4) + sanitized.substring(0, 4);
|
|
1208
|
-
let numericString = "";
|
|
1209
|
-
for (let i = 0; i < rearranged.length; i++) {
|
|
1210
|
-
const charCode = rearranged.charCodeAt(i);
|
|
1211
|
-
if (charCode >= 65 && charCode <= 90) {
|
|
1212
|
-
numericString += (charCode - 55).toString();
|
|
1213
|
-
} else if (charCode >= 48 && charCode <= 57) {
|
|
1214
|
-
numericString += rearranged.charAt(i);
|
|
1215
|
-
} else {
|
|
1216
|
-
return false;
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
try {
|
|
1220
|
-
return BigInt(numericString) % 97n === 1n;
|
|
1221
|
-
} catch (_e) {
|
|
1222
|
-
return false;
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
var PII_PATTERNS = {
|
|
1226
|
-
EMAIL: {
|
|
1227
|
-
name: "EMAIL",
|
|
1228
|
-
pattern: /\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b/gi,
|
|
1229
|
-
validator: (match) => !match.endsWith("@example.com") && !match.endsWith("@test.com")
|
|
1230
|
-
},
|
|
1231
|
-
CREDIT_CARD: {
|
|
1232
|
-
name: "CREDIT_CARD",
|
|
1233
|
-
pattern: /\b(?:\d[ -]*?){13,16}\b/g,
|
|
1234
|
-
validator: isLuhnValid
|
|
1235
|
-
},
|
|
1236
|
-
IP_ADDRESS: {
|
|
1237
|
-
name: "IP_ADDRESS",
|
|
1238
|
-
pattern: /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g,
|
|
1239
|
-
validator: (match) => {
|
|
1240
|
-
const safeIps = ["127.0.0.1", "0.0.0.0", "255.255.255.255"];
|
|
1241
|
-
if (safeIps.includes(match)) return false;
|
|
1242
|
-
const parts = match.split(".").map(Number);
|
|
1243
|
-
return parts.every((p) => p >= 0 && p <= 255);
|
|
1244
|
-
}
|
|
1245
|
-
},
|
|
1246
|
-
PHONE: {
|
|
1247
|
-
name: "PHONE",
|
|
1248
|
-
// Strict boundary to avoid matching long numeric IDs wrapped in symbols
|
|
1249
|
-
pattern: /(?:(?:\+?\d{1,3}[-. ]?)?\(?\d{3}\)?[-. ]?\d{3}[-. ]?\d{4})\b/g,
|
|
1250
|
-
validator: (match) => {
|
|
1251
|
-
const digits = match.replace(/\D/g, "");
|
|
1252
|
-
if (digits.length < 7 || digits.length > 15) return false;
|
|
1253
|
-
if (/^(\d)\1+$/.test(digits)) return false;
|
|
1254
|
-
if (digits === "1234567890") return false;
|
|
1255
|
-
return true;
|
|
1256
|
-
}
|
|
1257
|
-
},
|
|
1258
|
-
SSN: {
|
|
1259
|
-
name: "SSN",
|
|
1260
|
-
pattern: /\b\d{3}[- ]?\d{2}[- ]?\d{4}\b/g,
|
|
1261
|
-
validator: (match) => {
|
|
1262
|
-
const digits = match.replace(/\D/g, "");
|
|
1263
|
-
if (digits.length !== 9) return false;
|
|
1264
|
-
const area = parseInt(digits.substring(0, 3), 10);
|
|
1265
|
-
if (area === 0 || area === 666 || area >= 900) return false;
|
|
1266
|
-
const group = parseInt(digits.substring(3, 5), 10);
|
|
1267
|
-
if (group === 0) return false;
|
|
1268
|
-
const serial = parseInt(digits.substring(5, 9), 10);
|
|
1269
|
-
if (serial === 0) return false;
|
|
1270
|
-
if (/^(\d)\1+$/.test(digits) || digits === "123456789") return false;
|
|
1271
|
-
return true;
|
|
1272
|
-
}
|
|
1273
|
-
},
|
|
1274
|
-
IBAN: {
|
|
1275
|
-
name: "IBAN",
|
|
1276
|
-
pattern: /\b[A-Z]{2}[0-9]{2}[A-Z0-9]{1,30}\b/gi,
|
|
1277
|
-
validator: isIbanValid
|
|
1278
|
-
},
|
|
1279
|
-
PASSPORT_MRZ: {
|
|
1280
|
-
name: "PASSPORT_MRZ",
|
|
1281
|
-
// Machina Readable Zone line match for standard international passports
|
|
1282
|
-
pattern: /\bP[A-Z<][A-Z<]{3}[A-Z0-9<]{39}(?:\b|\s|$)/g
|
|
1283
|
-
}
|
|
1284
|
-
};
|
|
1285
|
-
var PII_PRESETS = {
|
|
1286
|
-
GLOBAL_STRICT: [
|
|
1287
|
-
PII_PATTERNS.EMAIL,
|
|
1288
|
-
PII_PATTERNS.CREDIT_CARD,
|
|
1289
|
-
PII_PATTERNS.IP_ADDRESS,
|
|
1290
|
-
PII_PATTERNS.PHONE,
|
|
1291
|
-
PII_PATTERNS.PASSPORT_MRZ,
|
|
1292
|
-
PII_PATTERNS.IBAN
|
|
1293
|
-
],
|
|
1294
|
-
US_COMPLIANT: [
|
|
1295
|
-
PII_PATTERNS.EMAIL,
|
|
1296
|
-
PII_PATTERNS.CREDIT_CARD,
|
|
1297
|
-
PII_PATTERNS.IP_ADDRESS,
|
|
1298
|
-
PII_PATTERNS.PHONE,
|
|
1299
|
-
PII_PATTERNS.SSN,
|
|
1300
|
-
PII_PATTERNS.PASSPORT_MRZ
|
|
1301
|
-
],
|
|
1302
|
-
EU_GDPR: [
|
|
1303
|
-
PII_PATTERNS.EMAIL,
|
|
1304
|
-
PII_PATTERNS.CREDIT_CARD,
|
|
1305
|
-
PII_PATTERNS.IP_ADDRESS,
|
|
1306
|
-
PII_PATTERNS.PHONE,
|
|
1307
|
-
PII_PATTERNS.IBAN,
|
|
1308
|
-
PII_PATTERNS.PASSPORT_MRZ
|
|
1309
|
-
]
|
|
1310
|
-
};
|
|
1311
|
-
var PiiScanner = class _PiiScanner {
|
|
1312
|
-
patterns;
|
|
1313
|
-
forbiddenKeysSet;
|
|
1314
|
-
nerScanner;
|
|
1315
|
-
/**
|
|
1316
|
-
* Safelist of keys that contain forbidden substrings but are NOT PII.
|
|
1317
|
-
* Prevents false positives from fuzzy matching (e.g., "grid" contains "id").
|
|
1318
|
-
*/
|
|
1319
|
-
static KEY_SAFELIST = /* @__PURE__ */ new Set([
|
|
1320
|
-
// Common words containing "id" substring
|
|
1321
|
-
"grid",
|
|
1322
|
-
"video",
|
|
1323
|
-
"android",
|
|
1324
|
-
"identity",
|
|
1325
|
-
"provide",
|
|
1326
|
-
"override",
|
|
1327
|
-
"validate",
|
|
1328
|
-
"hidden",
|
|
1329
|
-
"widget",
|
|
1330
|
-
"guidelines",
|
|
1331
|
-
"beside",
|
|
1332
|
-
"guideline",
|
|
1333
|
-
"outside",
|
|
1334
|
-
"inside",
|
|
1335
|
-
"collide",
|
|
1336
|
-
"decide",
|
|
1337
|
-
"divide",
|
|
1338
|
-
"aside",
|
|
1339
|
-
"ride",
|
|
1340
|
-
"side",
|
|
1341
|
-
"wide",
|
|
1342
|
-
"hide",
|
|
1343
|
-
"tide",
|
|
1344
|
-
"pride",
|
|
1345
|
-
"bride",
|
|
1346
|
-
"slide",
|
|
1347
|
-
"guide",
|
|
1348
|
-
"stride",
|
|
1349
|
-
"oxide",
|
|
1350
|
-
"dioxide",
|
|
1351
|
-
"suicide",
|
|
1352
|
-
"homicide",
|
|
1353
|
-
"pesticide",
|
|
1354
|
-
"valid",
|
|
1355
|
-
"invalid",
|
|
1356
|
-
"void",
|
|
1357
|
-
"avoid",
|
|
1358
|
-
// Common words containing "name" substring
|
|
1359
|
-
"diagnosis",
|
|
1360
|
-
"medication",
|
|
1361
|
-
"namespace",
|
|
1362
|
-
"namesake",
|
|
1363
|
-
"rename",
|
|
1364
|
-
"filename",
|
|
1365
|
-
"hostname",
|
|
1366
|
-
"typename",
|
|
1367
|
-
"unnamed",
|
|
1368
|
-
"renamed",
|
|
1369
|
-
// Common words containing "phone" substring
|
|
1370
|
-
"phonetic",
|
|
1371
|
-
"phoneme",
|
|
1372
|
-
"microphone",
|
|
1373
|
-
"headphone",
|
|
1374
|
-
"telephone",
|
|
1375
|
-
"saxophone",
|
|
1376
|
-
"smartphone",
|
|
1377
|
-
// Common words containing "address" substring
|
|
1378
|
-
"streetview",
|
|
1379
|
-
"addressable",
|
|
1380
|
-
"addressing",
|
|
1381
|
-
// Common words containing "city" substring
|
|
1382
|
-
"cityscape",
|
|
1383
|
-
"electricity",
|
|
1384
|
-
"capacity",
|
|
1385
|
-
"velocity",
|
|
1386
|
-
"opacity",
|
|
1387
|
-
// Common technical terms
|
|
1388
|
-
"timestamp",
|
|
1389
|
-
"timezone",
|
|
1390
|
-
// LIOP Protocol Internal Keys (must never be blocked)
|
|
1391
|
-
"image_id",
|
|
1392
|
-
"computation_result",
|
|
1393
|
-
"zk_receipt",
|
|
1394
|
-
"testid",
|
|
1395
|
-
"toolid",
|
|
1396
|
-
"sessionid",
|
|
1397
|
-
"peerid",
|
|
1398
|
-
"nodeid",
|
|
1399
|
-
"requestid",
|
|
1400
|
-
"correlationid",
|
|
1401
|
-
"traceid",
|
|
1402
|
-
"spanid"
|
|
1403
|
-
]);
|
|
1404
|
-
/**
|
|
1405
|
-
* Short forbidden tokens (< 4 chars) that require boundary-aware matching.
|
|
1406
|
-
* Uses regex boundary detection to avoid false positives.
|
|
1407
|
-
*/
|
|
1408
|
-
shortTokenBoundaryPatterns;
|
|
1409
|
-
/**
|
|
1410
|
-
* Long forbidden tokens (>= 4 chars) that use substring containment.
|
|
1411
|
-
*/
|
|
1412
|
-
longForbiddenTokens;
|
|
1413
|
-
constructor(patterns = [], forbiddenKeys = [], nerScanner) {
|
|
1414
|
-
this.patterns = patterns;
|
|
1415
|
-
this.forbiddenKeysSet = new Set(forbiddenKeys.map((k) => k.toLowerCase()));
|
|
1416
|
-
this.nerScanner = nerScanner ?? null;
|
|
1417
|
-
this.shortTokenBoundaryPatterns = /* @__PURE__ */ new Map();
|
|
1418
|
-
this.longForbiddenTokens = [];
|
|
1419
|
-
for (const token of this.forbiddenKeysSet) {
|
|
1420
|
-
if (token.length < 4) {
|
|
1421
|
-
this.shortTokenBoundaryPatterns.set(
|
|
1422
|
-
token,
|
|
1423
|
-
new RegExp(
|
|
1424
|
-
(() => {
|
|
1425
|
-
const ciPattern = token.split("").map((c) => `[${c.toLowerCase()}${c.toUpperCase()}]`).join("");
|
|
1426
|
-
const camelPattern = `[a-z]${token.charAt(0).toUpperCase()}${token.slice(1)}`;
|
|
1427
|
-
return `(?:^|[_-])${ciPattern}(?:$|[_-])|${camelPattern}(?:$|[A-Z_-])|^${ciPattern}$`;
|
|
1428
|
-
})()
|
|
1429
|
-
)
|
|
1430
|
-
);
|
|
1431
|
-
} else {
|
|
1432
|
-
this.longForbiddenTokens.push(token);
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
/**
|
|
1437
|
-
* Scans any input (string, object, array) for PII violations.
|
|
1438
|
-
* Returns the pattern/rule name that triggered the violation, or null if safe.
|
|
1439
|
-
*
|
|
1440
|
-
* Detection pipeline (fail-fast):
|
|
1441
|
-
* 1. Exact key match (O(1) Set lookup)
|
|
1442
|
-
* 2. Fuzzy key match (boundary detection for short tokens, substring for long)
|
|
1443
|
-
* 3. Regex/algorithmic pattern match on string values
|
|
1444
|
-
* 4. NER content scan on string values (if enabled)
|
|
1445
|
-
*/
|
|
1446
|
-
async scan(input, seen = /* @__PURE__ */ new WeakSet()) {
|
|
1447
|
-
if (input === null || input === void 0) return null;
|
|
1448
|
-
if (typeof input === "string") {
|
|
1449
|
-
const trimmed = input.trim();
|
|
1450
|
-
if (trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
1451
|
-
try {
|
|
1452
|
-
const parsed = JSON.parse(trimmed);
|
|
1453
|
-
const violation = await this.scan(parsed, seen);
|
|
1454
|
-
if (violation) return violation;
|
|
1455
|
-
} catch (_e) {
|
|
1456
|
-
}
|
|
1457
|
-
}
|
|
1458
|
-
const patternViolation = this.checkString(input);
|
|
1459
|
-
if (patternViolation) return patternViolation;
|
|
1460
|
-
if (this.nerScanner) {
|
|
1461
|
-
const nerResult = await this.nerScanner.scan(input);
|
|
1462
|
-
if (nerResult.detected) {
|
|
1463
|
-
const personEntity = nerResult.entities.find(
|
|
1464
|
-
(e) => e.type === "person"
|
|
1465
|
-
);
|
|
1466
|
-
if (personEntity) {
|
|
1467
|
-
return `PII Entity Detected: person name "${personEntity.text}"`;
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
return null;
|
|
1472
|
-
}
|
|
1473
|
-
if (typeof input === "object") {
|
|
1474
|
-
if (seen.has(input)) return null;
|
|
1475
|
-
seen.add(input);
|
|
1476
|
-
if (Array.isArray(input)) {
|
|
1477
|
-
for (const element of input) {
|
|
1478
|
-
const violation = await this.scan(element, seen);
|
|
1479
|
-
if (violation) return violation;
|
|
1480
|
-
}
|
|
1481
|
-
} else {
|
|
1482
|
-
for (const [key, value] of Object.entries(
|
|
1483
|
-
input
|
|
1484
|
-
)) {
|
|
1485
|
-
if (this.forbiddenKeysSet.has(key.toLowerCase())) {
|
|
1486
|
-
return `Forbidden Key: ${key}`;
|
|
1487
|
-
}
|
|
1488
|
-
const fuzzyViolation = this.checkKeyFuzzy(key);
|
|
1489
|
-
if (fuzzyViolation) return fuzzyViolation;
|
|
1490
|
-
const violation = await this.scan(value, seen);
|
|
1491
|
-
if (violation) return violation;
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
}
|
|
1495
|
-
return null;
|
|
1496
|
-
}
|
|
1497
|
-
/**
|
|
1498
|
-
* Checks a key against fuzzy matching rules.
|
|
1499
|
-
* Short tokens use boundary-aware regex; long tokens use substring containment.
|
|
1500
|
-
*/
|
|
1501
|
-
checkKeyFuzzy(key) {
|
|
1502
|
-
const normalized = key.toLowerCase();
|
|
1503
|
-
if (_PiiScanner.KEY_SAFELIST.has(normalized)) return null;
|
|
1504
|
-
for (const [token, pattern] of this.shortTokenBoundaryPatterns) {
|
|
1505
|
-
if (pattern.test(key)) {
|
|
1506
|
-
return `Forbidden Key (fuzzy): ${key} matches boundary pattern "${token}"`;
|
|
1507
|
-
}
|
|
1508
|
-
}
|
|
1509
|
-
for (const token of this.longForbiddenTokens) {
|
|
1510
|
-
if (normalized.includes(token)) {
|
|
1511
|
-
return `Forbidden Key (fuzzy): ${key} contains restricted token "${token}"`;
|
|
1512
|
-
}
|
|
1513
|
-
}
|
|
1514
|
-
return null;
|
|
1515
|
-
}
|
|
1516
|
-
checkString(text) {
|
|
1517
|
-
for (const rule of this.patterns) {
|
|
1518
|
-
if (typeof rule === "string") {
|
|
1519
|
-
if (text.toLowerCase().includes(rule.toLowerCase())) {
|
|
1520
|
-
return rule;
|
|
1521
|
-
}
|
|
1522
|
-
} else if (rule instanceof RegExp) {
|
|
1523
|
-
if (rule.global) rule.lastIndex = 0;
|
|
1524
|
-
if (rule.test(text)) {
|
|
1525
|
-
return rule.source;
|
|
1526
|
-
}
|
|
1527
|
-
} else if (typeof rule === "object" && rule !== null) {
|
|
1528
|
-
const def = rule;
|
|
1529
|
-
if (typeof def.pattern === "string") {
|
|
1530
|
-
if (text.toLowerCase().includes(def.pattern.toLowerCase())) {
|
|
1531
|
-
if (!def.validator || def.validator(def.pattern)) {
|
|
1532
|
-
return def.name;
|
|
1533
|
-
}
|
|
1534
|
-
}
|
|
1535
|
-
} else if (def.pattern instanceof RegExp) {
|
|
1536
|
-
if (def.pattern.global) def.pattern.lastIndex = 0;
|
|
1537
|
-
let match = def.pattern.exec(text);
|
|
1538
|
-
while (match !== null) {
|
|
1539
|
-
const matchedText = match[0];
|
|
1540
|
-
if (!def.validator || def.validator(matchedText)) {
|
|
1541
|
-
return def.name;
|
|
1542
|
-
}
|
|
1543
|
-
if (!def.pattern.global) break;
|
|
1544
|
-
match = def.pattern.exec(text);
|
|
1545
|
-
}
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
}
|
|
1549
|
-
return null;
|
|
1550
|
-
}
|
|
1551
|
-
};
|
|
1552
|
-
|
|
1553
|
-
// src/server/index.ts
|
|
1554
|
-
var __dirname2 = path.dirname(fileURLToPath(import.meta.url));
|
|
1555
|
-
var LiopServer = class _LiopServer {
|
|
1556
|
-
constructor(serverInfo, config) {
|
|
1557
|
-
this.serverInfo = serverInfo;
|
|
1558
|
-
this.config = config;
|
|
1559
|
-
const nerScanner = this.config?.security?.enableNerScanning ? new NerScanner() : null;
|
|
1560
|
-
this.piiScanner = new PiiScanner(
|
|
1561
|
-
this.config?.security?.piiPatterns ?? PII_PRESETS.GLOBAL_STRICT,
|
|
1562
|
-
this.config?.security?.forbiddenKeys ?? [
|
|
1563
|
-
"id",
|
|
1564
|
-
"name",
|
|
1565
|
-
"fullName",
|
|
1566
|
-
"firstName",
|
|
1567
|
-
"lastName",
|
|
1568
|
-
"address",
|
|
1569
|
-
"street",
|
|
1570
|
-
"city",
|
|
1571
|
-
"postalCode",
|
|
1572
|
-
"zipCode",
|
|
1573
|
-
"phone",
|
|
1574
|
-
"email",
|
|
1575
|
-
"ssn",
|
|
1576
|
-
"accountHolder",
|
|
1577
|
-
"accountNumber",
|
|
1578
|
-
"account_number",
|
|
1579
|
-
"password",
|
|
1580
|
-
"token",
|
|
1581
|
-
"secret",
|
|
1582
|
-
"privateKey"
|
|
1583
|
-
],
|
|
1584
|
-
nerScanner
|
|
1585
|
-
);
|
|
1586
|
-
const rlConfig = this.config?.security?.rateLimit;
|
|
1587
|
-
this.toolCallWindowMs = rlConfig?.windowMs ?? Number.parseInt(process.env.LIOP_RATE_LIMIT_WINDOW_MS ?? "60000", 10);
|
|
1588
|
-
this.toolCallMaxPerWindow = rlConfig?.maxPerWindow ?? Number.parseInt(process.env.LIOP_RATE_LIMIT_MAX ?? "15", 10);
|
|
1589
|
-
this.globalCallMaxPerWindow = rlConfig?.globalMaxPerWindow ?? Number.parseInt(process.env.LIOP_RATE_LIMIT_GLOBAL_MAX ?? "40", 10);
|
|
1590
|
-
const forbiddenKeys = this.config?.security?.forbiddenKeys ?? [
|
|
1591
|
-
"id",
|
|
1592
|
-
"name",
|
|
1593
|
-
"fullName",
|
|
1594
|
-
"firstName",
|
|
1595
|
-
"lastName",
|
|
1596
|
-
"address",
|
|
1597
|
-
"street",
|
|
1598
|
-
"city",
|
|
1599
|
-
"postalCode",
|
|
1600
|
-
"zipCode",
|
|
1601
|
-
"phone",
|
|
1602
|
-
"email",
|
|
1603
|
-
"ssn",
|
|
1604
|
-
"accountHolder",
|
|
1605
|
-
"accountNumber",
|
|
1606
|
-
"account_number",
|
|
1607
|
-
"password",
|
|
1608
|
-
"token",
|
|
1609
|
-
"secret",
|
|
1610
|
-
"privateKey"
|
|
1611
|
-
];
|
|
1612
|
-
const sensitiveKeys = this.config?.security?.sensitiveKeys ?? [];
|
|
1613
|
-
this.taintAnalyzer = new TaintAnalyzer(forbiddenKeys, sensitiveKeys);
|
|
1614
|
-
const isTS = import.meta.url.endsWith(".ts");
|
|
1615
|
-
const workerExt = isTS ? ".ts" : ".js";
|
|
1616
|
-
let execArgv = [];
|
|
1617
|
-
if (isTS) {
|
|
1618
|
-
try {
|
|
1619
|
-
const req = createRequire(import.meta.url);
|
|
1620
|
-
const tsxPkg = req.resolve("tsx/package.json");
|
|
1621
|
-
const absoluteTsx = pathToFileURL(
|
|
1622
|
-
path.join(path.dirname(tsxPkg), "dist", "loader.mjs")
|
|
1623
|
-
).href;
|
|
1624
|
-
execArgv = ["--import", absoluteTsx];
|
|
1625
|
-
} catch (_e) {
|
|
1626
|
-
execArgv = ["--import", "tsx"];
|
|
1627
|
-
}
|
|
1628
|
-
}
|
|
1629
|
-
const isTest = process.env.NODE_ENV === "test" || process.env.VITEST;
|
|
1630
|
-
if (this.config?.capabilities && !this.serverInfo.capabilities) {
|
|
1631
|
-
this.serverInfo.capabilities = this.config.capabilities;
|
|
1632
|
-
}
|
|
1633
|
-
const workerPaths = [
|
|
1634
|
-
path.resolve(__dirname2, `./workers/logic-execution${workerExt}`),
|
|
1635
|
-
// Flat dist/ (tsup)
|
|
1636
|
-
path.resolve(__dirname2, `../workers/logic-execution${workerExt}`)
|
|
1637
|
-
// Original src/
|
|
1638
|
-
];
|
|
1639
|
-
const workerFilename = workerPaths.find((p) => fs.existsSync(p)) || workerPaths[1];
|
|
1640
|
-
this.workerPool = new Piscina({
|
|
1641
|
-
filename: workerFilename,
|
|
1642
|
-
minThreads: this.config?.workerPool?.minThreads ?? (isTest ? 0 : 2),
|
|
1643
|
-
maxThreads: this.config?.workerPool?.maxThreads ?? (isTest ? 1 : 8),
|
|
1644
|
-
idleTimeout: this.config?.workerPool?.idleTimeout ?? (isTest ? 500 : 5e3),
|
|
1645
|
-
maxQueue: this.config?.workerPool?.maxQueue ?? "auto",
|
|
1646
|
-
taskQueue: new FixedQueue(),
|
|
1647
|
-
execArgv,
|
|
1648
|
-
// [DoS Defense] Enforce hard memory ceiling per worker thread.
|
|
1649
|
-
// Workers exceeding this limit are terminated by Node.js runtime.
|
|
1650
|
-
resourceLimits: {
|
|
1651
|
-
maxOldGenerationSizeMb: this.config?.workerPool?.maxHeapMb ?? Number.parseInt(process.env.LIOP_WORKER_MAX_HEAP_MB ?? "64", 10)
|
|
1652
|
-
}
|
|
1653
|
-
});
|
|
1654
|
-
const minThreads = this.config?.workerPool?.minThreads ?? (isTest ? 0 : 2);
|
|
1655
|
-
if (this.workerPool && minThreads > 0) {
|
|
1656
|
-
for (let i = 0; i < minThreads; i++) {
|
|
1657
|
-
this.workerPool.run({ isWarmup: true }).catch((err) => {
|
|
1658
|
-
log.debug(
|
|
1659
|
-
`[LiopServer] Worker pool warm-up ping failed: ${err.message}`
|
|
1660
|
-
);
|
|
1661
|
-
});
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
|
-
if (this.config?.auth?.role === "nexus") {
|
|
1665
|
-
const issuer = this.config.auth.issuer || "http://localhost:3000";
|
|
1666
|
-
const audience = this.config.auth.audience || AUTH_DEFAULTS.audience;
|
|
1667
|
-
const clients = this.config.auth.clients || [
|
|
1668
|
-
{
|
|
1669
|
-
client_id: process.env.LIOP_OAUTH_CLIENT_ID || "liop-mesh-agent",
|
|
1670
|
-
client_secret: process.env.LIOP_OAUTH_CLIENT_SECRET || "dev-secret-change-me",
|
|
1671
|
-
grant_types: ["client_credentials"],
|
|
1672
|
-
scope: "liop:tools:call liop:tools:list liop:resources:read liop:schema:read liop:mesh:query"
|
|
1673
|
-
}
|
|
1674
|
-
];
|
|
1675
|
-
const { provider, jwks } = createOAuthServer({
|
|
1676
|
-
issuer,
|
|
1677
|
-
clients
|
|
1678
|
-
});
|
|
1679
|
-
this.oauthProvider = provider;
|
|
1680
|
-
this.jwtValidator = new JwtValidator(issuer, audience, jwks);
|
|
1681
|
-
} else if (this.config?.auth?.role === "node") {
|
|
1682
|
-
const nexusUrl = this.config.auth.nexusUrl || process.env.LIOP_NEXUS_URL || "http://localhost:3000";
|
|
1683
|
-
const audience = this.config.auth.audience || AUTH_DEFAULTS.audience;
|
|
1684
|
-
const baseUrl = nexusUrl.endsWith("/oidc") ? nexusUrl : `${nexusUrl}/oidc`;
|
|
1685
|
-
const jwksUri = new URL(`${baseUrl}/jwks`);
|
|
1686
|
-
this.jwtValidator = new JwtValidator(nexusUrl, audience, jwksUri);
|
|
1687
|
-
}
|
|
1688
|
-
this.resource(
|
|
1689
|
-
"LIOP Envelope Specification",
|
|
1690
|
-
"liop://protocol/envelope-spec",
|
|
1691
|
-
"Complete Logic-on-Origin envelope format, execution rules, and security constraints",
|
|
1692
|
-
"text/plain",
|
|
1693
|
-
() => Promise.resolve(this.buildEnvelopeSpec())
|
|
1694
|
-
);
|
|
1695
|
-
if (this.config?.auth?.revocationPath) {
|
|
1696
|
-
this.loadRevocationList();
|
|
1697
|
-
}
|
|
1698
|
-
}
|
|
1699
|
-
logicCache = /* @__PURE__ */ new Map();
|
|
1700
|
-
connectionStats = /* @__PURE__ */ new Map();
|
|
1701
|
-
CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
1702
|
-
// 24 hours
|
|
1703
|
-
THROTTLE_THRESHOLD = 5;
|
|
1704
|
-
THROTTLE_COOLDOWN_MS = 60 * 1e3;
|
|
1705
|
-
// 60 seconds
|
|
1706
|
-
// [OWASP-A01] Sliding window rate limiter — prevents micro-query exfiltration
|
|
1707
|
-
toolCallWindows = /* @__PURE__ */ new Map();
|
|
1708
|
-
toolCallMaxPerWindow;
|
|
1709
|
-
toolCallWindowMs;
|
|
1710
|
-
// [OWASP-A01] Global cross-tool rate limiter — prevents distributed micro-query attacks
|
|
1711
|
-
globalCallWindow = [];
|
|
1712
|
-
globalCallMaxPerWindow;
|
|
1713
|
-
// [DP] Query Budget — tracks per-field query counts to prevent multi-query differencing
|
|
1714
|
-
// Structure: clientId -> toolName -> field -> count
|
|
1715
|
-
fieldQueryBudget = /* @__PURE__ */ new Map();
|
|
1716
|
-
// [SEC] AST-level taint tracker for PII side-channel prevention
|
|
1717
|
-
taintAnalyzer;
|
|
1718
|
-
tools = /* @__PURE__ */ new Map();
|
|
1719
|
-
resources = /* @__PURE__ */ new Map();
|
|
1720
|
-
prompts = /* @__PURE__ */ new Map();
|
|
1721
|
-
activeSchema = null;
|
|
1722
|
-
sandboxRecords = [];
|
|
1723
|
-
piiScanner;
|
|
1724
|
-
workerPool;
|
|
1725
|
-
meshNode = null;
|
|
1726
|
-
rpcServer = null;
|
|
1727
|
-
boundPort = null;
|
|
1728
|
-
jwtValidator;
|
|
1729
|
-
// biome-ignore lint/suspicious/noExplicitAny: Loaded dynamically in Phase C
|
|
1730
|
-
oauthProvider;
|
|
1731
|
-
sessions = /* @__PURE__ */ new Map();
|
|
1732
|
-
revokedTokenHashes = /* @__PURE__ */ new Set();
|
|
1733
|
-
lastRevocationLoadTime = 0;
|
|
1734
|
-
// Compact envelope: @LIOP{target,name}\n<code>\n@END
|
|
1735
|
-
static LIOP_COMPACT_REGEX = /@LIOP\{(?<target>[^,}]+)(?:,(?<name>[^}]*))?\}\n(?<logic>[\s\S]*?)\n@END/m;
|
|
1736
|
-
extractLogic(payload) {
|
|
1737
|
-
const compact = payload.match(_LiopServer.LIOP_COMPACT_REGEX);
|
|
1738
|
-
return compact?.groups?.logic ? compact.groups.logic.trim() : null;
|
|
1739
|
-
}
|
|
1740
|
-
parseUnknownJson(input) {
|
|
1741
|
-
if (typeof input !== "string") return input;
|
|
1742
|
-
const trimmed = input.trim();
|
|
1743
|
-
if (trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
1744
|
-
try {
|
|
1745
|
-
return JSON.parse(trimmed);
|
|
1746
|
-
} catch {
|
|
1747
|
-
return input;
|
|
1748
|
-
}
|
|
1749
|
-
}
|
|
1750
|
-
return input;
|
|
1751
|
-
}
|
|
1752
|
-
runPreflightPolicy(_toolName, logic, policy, clientId = "local-client") {
|
|
1753
|
-
if (policy) {
|
|
1754
|
-
const compact = logic.replace(/\s+/g, " ");
|
|
1755
|
-
if (policy.enforceAggregationFirst) {
|
|
1756
|
-
const rowExtractionPatterns = [
|
|
1757
|
-
// Block raw record dumps but allow safe aggregation chains
|
|
1758
|
-
// (.reduce, .length, .filter().length, .every, .some)
|
|
1759
|
-
/return\s+env\.records(?!\s*\.\s*(?:reduce|length|filter|every|some|find)\b)/i,
|
|
1760
|
-
/return\s*\{[\s\S]*\b(accounts|patients|rows|records)\s*:\s*env\.records(?!\s*\.\s*(?:reduce|length|filter)\b)/i
|
|
1761
|
-
];
|
|
1762
|
-
if (rowExtractionPatterns.some((p) => p.test(compact))) {
|
|
1763
|
-
return "Preflight policy rejected: potential row-level export pattern detected.";
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
|
-
if (policy.preflightDenyPatterns?.some((p) => p.test(compact))) {
|
|
1767
|
-
return "Preflight policy rejected: custom deny pattern matched.";
|
|
1768
|
-
}
|
|
1769
|
-
}
|
|
1770
|
-
let minMaxThreshold = 50;
|
|
1771
|
-
if (typeof policy?.enforceAggregationFirst === "object") {
|
|
1772
|
-
minMaxThreshold = policy.enforceAggregationFirst.minMaxBlockThreshold ?? 50;
|
|
1773
|
-
}
|
|
1774
|
-
const taintViolation = this.taintAnalyzer.analyze(
|
|
1775
|
-
logic,
|
|
1776
|
-
this.sandboxRecords.length,
|
|
1777
|
-
minMaxThreshold
|
|
1778
|
-
);
|
|
1779
|
-
if (taintViolation) {
|
|
1780
|
-
return `Preflight policy rejected: ${taintViolation.reason}`;
|
|
1781
|
-
}
|
|
1782
|
-
const extractedFields = this.taintAnalyzer.extractQueriedFields(logic);
|
|
1783
|
-
if (extractedFields.length > 0) {
|
|
1784
|
-
const storePath = policy?.budgetStorePath || this.config?.budgetStorePath;
|
|
1785
|
-
if (storePath) {
|
|
1786
|
-
try {
|
|
1787
|
-
const budgetError = this.executeWithBudgetLock(
|
|
1788
|
-
storePath,
|
|
1789
|
-
(budget) => {
|
|
1790
|
-
const clientBudget = budget[clientId] || {};
|
|
1791
|
-
const toolBudget = clientBudget[_toolName] || {};
|
|
1792
|
-
for (const field of extractedFields) {
|
|
1793
|
-
const sensitivity = this.taintAnalyzer.classifyField(
|
|
1794
|
-
field,
|
|
1795
|
-
policy?.sensitiveKeys
|
|
1796
|
-
);
|
|
1797
|
-
let queryLimit = 25;
|
|
1798
|
-
let sensLabel = "public";
|
|
1799
|
-
if (policy?.queryBudgetPerField !== void 0) {
|
|
1800
|
-
queryLimit = policy.queryBudgetPerField;
|
|
1801
|
-
sensLabel = "override";
|
|
1802
|
-
} else if (sensitivity === "forbidden") {
|
|
1803
|
-
queryLimit = 3;
|
|
1804
|
-
sensLabel = "forbidden";
|
|
1805
|
-
} else if (sensitivity === "sensitive") {
|
|
1806
|
-
queryLimit = 8;
|
|
1807
|
-
sensLabel = "sensitive";
|
|
1808
|
-
}
|
|
1809
|
-
const count = toolBudget[field] ?? 0;
|
|
1810
|
-
if (count >= queryLimit) {
|
|
1811
|
-
return {
|
|
1812
|
-
result: `Preflight policy rejected: Query budget exceeded for field '${field}' (max ${queryLimit} per session for ${sensLabel} fields). Rotate PQC session to reset budget.`
|
|
1813
|
-
};
|
|
1814
|
-
}
|
|
1815
|
-
}
|
|
1816
|
-
const updatedToolBudget = { ...toolBudget };
|
|
1817
|
-
for (const field of extractedFields) {
|
|
1818
|
-
updatedToolBudget[field] = (updatedToolBudget[field] ?? 0) + 1;
|
|
1819
|
-
}
|
|
1820
|
-
const updatedClientBudget = { ...clientBudget };
|
|
1821
|
-
updatedClientBudget[_toolName] = updatedToolBudget;
|
|
1822
|
-
const updatedBudget = { ...budget };
|
|
1823
|
-
updatedBudget[clientId] = updatedClientBudget;
|
|
1824
|
-
return { result: null, updatedBudget };
|
|
1825
|
-
}
|
|
1826
|
-
);
|
|
1827
|
-
if (budgetError) {
|
|
1828
|
-
return budgetError;
|
|
1829
|
-
}
|
|
1830
|
-
} catch (e) {
|
|
1831
|
-
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
1832
|
-
log.error(
|
|
1833
|
-
`[LIOP-Server] Error applying persistent query budget: ${errorMsg}. Falling back to in-memory.`
|
|
1834
|
-
);
|
|
1835
|
-
const inMemoryError = this.applyInMemoryBudget(
|
|
1836
|
-
clientId,
|
|
1837
|
-
_toolName,
|
|
1838
|
-
extractedFields,
|
|
1839
|
-
policy
|
|
1840
|
-
);
|
|
1841
|
-
if (inMemoryError) return inMemoryError;
|
|
1842
|
-
}
|
|
1843
|
-
} else {
|
|
1844
|
-
const inMemoryError = this.applyInMemoryBudget(
|
|
1845
|
-
clientId,
|
|
1846
|
-
_toolName,
|
|
1847
|
-
extractedFields,
|
|
1848
|
-
policy
|
|
1849
|
-
);
|
|
1850
|
-
if (inMemoryError) return inMemoryError;
|
|
1851
|
-
}
|
|
1852
|
-
}
|
|
1853
|
-
return null;
|
|
1854
|
-
}
|
|
1855
|
-
validateOutputPolicy(toolName, output, policy) {
|
|
1856
|
-
if (!policy) return null;
|
|
1857
|
-
const parsed = this.parseUnknownJson(output);
|
|
1858
|
-
if (parsed && typeof parsed === "object" && parsed.isError === true) {
|
|
1859
|
-
return null;
|
|
1860
|
-
}
|
|
1861
|
-
if (policy.outputSchema) {
|
|
1862
|
-
const effectiveSchema = (() => {
|
|
1863
|
-
if (!(policy.outputSchema instanceof z.ZodObject)) {
|
|
1864
|
-
return policy.outputSchema;
|
|
1865
|
-
}
|
|
1866
|
-
const obj = policy.outputSchema;
|
|
1867
|
-
if (!(obj._def.catchall instanceof z.ZodNever)) {
|
|
1868
|
-
return obj;
|
|
1869
|
-
}
|
|
1870
|
-
return obj.strict();
|
|
1871
|
-
})();
|
|
1872
|
-
const schemaResult = effectiveSchema.safeParse(parsed);
|
|
1873
|
-
if (!schemaResult.success) {
|
|
1874
|
-
return `[LIOP] Output schema violation for ${toolName}: ${schemaResult.error.issues.map((i) => `${i.path.join(".") || "<root>"} ${i.message}`).join(
|
|
1875
|
-
"; "
|
|
1876
|
-
)}. HINT: Your output must conform to the declared schema. Use 'env.records' to access the dataset and return only allowed fields.`;
|
|
1877
|
-
}
|
|
1878
|
-
}
|
|
1879
|
-
if (policy.enforceAggregationFirst && this.violatesAggregationFirstPolicy(
|
|
1880
|
-
this.unwrapForAggregationPolicyScan(parsed),
|
|
1881
|
-
policy.enforceAggregationFirst,
|
|
1882
|
-
this.sandboxRecords.length
|
|
1883
|
-
)) {
|
|
1884
|
-
const isDev = process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test" || process.env.LIOP_SEC_VERBOSE === "1";
|
|
1885
|
-
return isDev ? "Aggregation-First Policy Violation: row-level export or K-Anonymity violation blocked. HINT: Use .reduce() to produce a flat {key:value} object. Do NOT use .map() to create arrays of objects. Ensure dataset size > 10 for detailed results." : "Aggregation-First Policy Violation: Output blocked due to privacy constraints.";
|
|
1886
|
-
}
|
|
1887
|
-
return null;
|
|
1888
|
-
}
|
|
1889
|
-
/**
|
|
1890
|
-
* Proxied tools stringify a full MCP CallToolResult (`{ content: [...] }`).
|
|
1891
|
-
* Aggregation-first heuristics must scan the inner business JSON, not the MCP envelope
|
|
1892
|
-
* (otherwise `content` looks like a tabular array of objects and everything blocks).
|
|
1893
|
-
*/
|
|
1894
|
-
unwrapForAggregationPolicyScan(input) {
|
|
1895
|
-
if (typeof input === "string") {
|
|
1896
|
-
const trimmed = input.trim();
|
|
1897
|
-
if (trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
1898
|
-
try {
|
|
1899
|
-
return this.unwrapForAggregationPolicyScan(JSON.parse(trimmed));
|
|
1900
|
-
} catch {
|
|
1901
|
-
return input;
|
|
1902
|
-
}
|
|
1903
|
-
}
|
|
1904
|
-
return input;
|
|
1905
|
-
}
|
|
1906
|
-
if (!input || typeof input !== "object") {
|
|
1907
|
-
return input;
|
|
1908
|
-
}
|
|
1909
|
-
const rec = input;
|
|
1910
|
-
if (rec.computation_result !== void 0) {
|
|
1911
|
-
return this.unwrapForAggregationPolicyScan(rec.computation_result);
|
|
1912
|
-
}
|
|
1913
|
-
if (!Array.isArray(rec.content) || rec.content.length === 0) {
|
|
1914
|
-
return input;
|
|
1915
|
-
}
|
|
1916
|
-
const texts = [];
|
|
1917
|
-
for (const part of rec.content) {
|
|
1918
|
-
if (part && typeof part === "object" && "text" in part) {
|
|
1919
|
-
const t = part.text;
|
|
1920
|
-
if (typeof t === "string") {
|
|
1921
|
-
texts.push(t);
|
|
1922
|
-
}
|
|
1923
|
-
}
|
|
1924
|
-
}
|
|
1925
|
-
if (texts.length === 0) {
|
|
1926
|
-
return input;
|
|
1927
|
-
}
|
|
1928
|
-
const joined = texts.length === 1 ? texts[0] : texts.join("\n");
|
|
1929
|
-
return this.unwrapForAggregationPolicyScan(joined);
|
|
1930
|
-
}
|
|
1931
|
-
violatesAggregationFirstPolicy(input, policyObj, recordsCount) {
|
|
1932
|
-
if (!policyObj) {
|
|
1933
|
-
return false;
|
|
1934
|
-
}
|
|
1935
|
-
const maxRows = typeof policyObj === "object" && typeof policyObj.maxOutputRows === "number" ? policyObj.maxOutputRows : 10;
|
|
1936
|
-
const allowPrimitives = typeof policyObj === "object" && typeof policyObj.allowPrimitiveArrays === "boolean" ? policyObj.allowPrimitiveArrays : true;
|
|
1937
|
-
if (typeof input === "string") {
|
|
1938
|
-
const trimmed = input.trim();
|
|
1939
|
-
if (trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
1940
|
-
try {
|
|
1941
|
-
return this.violatesAggregationFirstPolicy(
|
|
1942
|
-
JSON.parse(trimmed),
|
|
1943
|
-
policyObj,
|
|
1944
|
-
recordsCount
|
|
1945
|
-
);
|
|
1946
|
-
} catch {
|
|
1947
|
-
return false;
|
|
1948
|
-
}
|
|
1949
|
-
}
|
|
1950
|
-
return false;
|
|
1951
|
-
}
|
|
1952
|
-
if (Array.isArray(input)) {
|
|
1953
|
-
if (input.length > 0 && input.every((item) => typeof item === "object" && item !== null)) {
|
|
1954
|
-
if (input.length > maxRows) {
|
|
1955
|
-
return true;
|
|
1956
|
-
}
|
|
1957
|
-
return input.some(
|
|
1958
|
-
(item) => this.violatesAggregationFirstPolicy(item, policyObj, recordsCount)
|
|
1959
|
-
);
|
|
1960
|
-
}
|
|
1961
|
-
if (input.length > 0 && input.every((item) => typeof item !== "object" || item === null)) {
|
|
1962
|
-
if (!allowPrimitives) return true;
|
|
1963
|
-
return false;
|
|
1964
|
-
}
|
|
1965
|
-
return input.some(
|
|
1966
|
-
(item) => this.violatesAggregationFirstPolicy(item, policyObj, recordsCount)
|
|
1967
|
-
);
|
|
1968
|
-
}
|
|
1969
|
-
if (input && typeof input === "object") {
|
|
1970
|
-
const keys = Object.keys(input);
|
|
1971
|
-
if (recordsCount !== void 0 && recordsCount > 0 && recordsCount < 10) {
|
|
1972
|
-
if (keys.length > 3) return true;
|
|
1973
|
-
const values = Object.values(input);
|
|
1974
|
-
if (values.some(
|
|
1975
|
-
(v) => Array.isArray(v) || typeof v === "object" && v !== null
|
|
1976
|
-
)) {
|
|
1977
|
-
return true;
|
|
1978
|
-
}
|
|
1979
|
-
}
|
|
1980
|
-
if (keys.length > maxRows) {
|
|
1981
|
-
return true;
|
|
1982
|
-
}
|
|
1983
|
-
return Object.values(input).some(
|
|
1984
|
-
(value) => this.violatesAggregationFirstPolicy(value, policyObj, recordsCount)
|
|
1985
|
-
);
|
|
1986
|
-
}
|
|
1987
|
-
return false;
|
|
1988
|
-
}
|
|
1989
|
-
/**
|
|
1990
|
-
* Builds the centralized LIOP envelope specification document.
|
|
1991
|
-
* Served as a single Resource (liop://protocol/envelope-spec) instead
|
|
1992
|
-
* of being duplicated across every tool description.
|
|
1993
|
-
*/
|
|
1994
|
-
buildEnvelopeSpec() {
|
|
1995
|
-
const lines = [
|
|
1996
|
-
"LIOP v1 Envelope Specification",
|
|
1997
|
-
"================================",
|
|
1998
|
-
"",
|
|
1999
|
-
"FORMAT:",
|
|
2000
|
-
"",
|
|
2001
|
-
"Compact Envelope:",
|
|
2002
|
-
" @LIOP{wasi_v1,TaskName}",
|
|
2003
|
-
" <JavaScript code>",
|
|
2004
|
-
" @END",
|
|
2005
|
-
"",
|
|
2006
|
-
"RUNTIME ENVIRONMENT:",
|
|
2007
|
-
"- env.records: Array of data objects from the origin",
|
|
2008
|
-
"- Must use 'return' to output results",
|
|
2009
|
-
"- Zero-Trust WASI Sandbox (Node.js Worker Pool)",
|
|
2010
|
-
"- Return aggregated objects, NOT raw row-level arrays",
|
|
2011
|
-
"",
|
|
2012
|
-
"SANDBOX RUNTIME RESTRICTIONS & WORKAROUNDS:",
|
|
2013
|
-
"- Date is poisoned: The 'Date' class/constructor is undefined (Date.now(), Date.parse(), etc. will throw).",
|
|
2014
|
-
" Workaround: Use lexicographical string comparison on ISO 8601 date strings (e.g., record.date >= '2024-01-01').",
|
|
2015
|
-
"- Poisoned globals: eval, Function, setTimeout, setInterval, Buffer, ArrayBuffer, and TypedArrays are undefined.",
|
|
2016
|
-
"- Frozen prototypes: Any modifications to Object.prototype, Array.prototype, etc., are blocked.",
|
|
2017
|
-
"",
|
|
2018
|
-
"SECURITY CONSTRAINTS:",
|
|
2019
|
-
"- PII Egress Shield blocks raw identifiers in output",
|
|
2020
|
-
"- Aggregation-First policy: prefer counts, averages, summaries",
|
|
2021
|
-
"- AST Guardian: static analysis before execution",
|
|
2022
|
-
"",
|
|
2023
|
-
"DIFFERENTIAL PRIVACY (DP) MECHANISM (Laplace Mechanism):",
|
|
2024
|
-
"- Default field noise scale is derived from node global sensitivity.",
|
|
2025
|
-
"- COUNT / LENGTH Optimization: To obtain EXACT counts without noise (sensitivity=1),",
|
|
2026
|
-
" the return keys MUST contain 'count', 'length', 'size', 'num', 'positive', 'negative',",
|
|
2027
|
-
" or start with 'total_' or 'num_' (e.g. 'total_tx', 'credits_count').",
|
|
2028
|
-
"- AVERAGE Optimization: Keys containing 'avg', 'mean' or 'average' scale noise",
|
|
2029
|
-
" down automatically by dividing sensitivity by dataset size (sensitivity / n).",
|
|
2030
|
-
"- SUM / OTHER queries: Receive full Laplace noise based on global node sensitivity",
|
|
2031
|
-
" (e.g., Sensitivity=100,000 in Bank to protect balances)."
|
|
2032
|
-
];
|
|
2033
|
-
if (this.config?.security?.forbiddenKeys?.length) {
|
|
2034
|
-
lines.push(
|
|
2035
|
-
`- Restricted fields: ${this.config.security.forbiddenKeys.join(", ")}`
|
|
2036
|
-
);
|
|
2037
|
-
}
|
|
2038
|
-
lines.push(
|
|
2039
|
-
"",
|
|
2040
|
-
"TAINT TRACKING (Phase 108):",
|
|
2041
|
-
"- AST-level analysis blocks PII-derived scalars (charCodeAt, charAt, etc.)",
|
|
2042
|
-
"- Operations on restricted fields are tracked through variable assignments",
|
|
2043
|
-
"- Boolean inference (field.charCodeAt(0) < N ? 1 : 0) is blocked",
|
|
2044
|
-
"- Allowed: aggregations on non-PII fields (balance, amount, date)",
|
|
2045
|
-
"",
|
|
2046
|
-
"K-ANONYMITY THRESHOLDS:",
|
|
2047
|
-
"- Small Datasets (< 10 records): Maximum of 3 scalar output fields. Nesting or arrays in output are strictly forbidden.",
|
|
2048
|
-
"- Large Datasets (>= 10 records): Maximum of 10 output fields.",
|
|
2049
|
-
"",
|
|
2050
|
-
"RATE LIMITS (OWASP A01):",
|
|
2051
|
-
"- Per-tool: 15 calls/min (configurable via LIOP_RATE_LIMIT_MAX)",
|
|
2052
|
-
"- Global: 40 calls/min across all tools (LIOP_RATE_LIMIT_GLOBAL_MAX)",
|
|
2053
|
-
"",
|
|
2054
|
-
"OPTIONAL PARAMETERS:",
|
|
2055
|
-
"- __liop_bypass_ast_cache: boolean (force AST re-evaluation)",
|
|
2056
|
-
"",
|
|
2057
|
-
"AUTHENTICATION (tokenSlug Convention):",
|
|
2058
|
-
"- Restricted nodes declare authRequired: true in their manifest.",
|
|
2059
|
-
"- Token resolution: LIOP_TOKEN_<tokenSlug> (deterministic) > LIOP_TOKEN_<PeerID> > LIOP_TOKEN_<ProviderName>",
|
|
2060
|
-
"- Format: tokenSlug must match SCREAMING_SNAKE_CASE /^[A-Z][A-Z0-9_]*$/ (e.g., BANK, VAULT, HFT_ORACLE)."
|
|
2061
|
-
);
|
|
2062
|
-
return lines.join("\n");
|
|
2063
|
-
}
|
|
2064
|
-
/**
|
|
2065
|
-
* Extracts a compact, human-readable field summary from a JSON Schema.
|
|
2066
|
-
*
|
|
2067
|
-
* Walks the schema structure to find actual data property names and types,
|
|
2068
|
-
* rather than returning top-level schema metadata keys (type, items, etc.).
|
|
2069
|
-
*
|
|
2070
|
-
* Example output for a banking schema:
|
|
2071
|
-
* "Array of {id(string), accountHolder(string), balance(number), transactions(array of {date(string), amount(number)})}"
|
|
2072
|
-
*/
|
|
2073
|
-
extractSchemaFieldSummary(schema, depth = 0) {
|
|
2074
|
-
if (depth > 3) return "{...}";
|
|
2075
|
-
const schemaType = schema.type;
|
|
2076
|
-
const properties = schema.properties;
|
|
2077
|
-
const items = schema.items;
|
|
2078
|
-
if (properties) {
|
|
2079
|
-
const fields = Object.entries(properties).map(([key, prop]) => {
|
|
2080
|
-
const propType = prop.type;
|
|
2081
|
-
if (propType === "array" && prop.items) {
|
|
2082
|
-
const nested = this.extractSchemaFieldSummary(
|
|
2083
|
-
prop.items,
|
|
2084
|
-
depth + 1
|
|
2085
|
-
);
|
|
2086
|
-
return `${key}(array of ${nested})`;
|
|
2087
|
-
}
|
|
2088
|
-
if (propType === "object" && prop.properties) {
|
|
2089
|
-
const nested = this.extractSchemaFieldSummary(prop, depth + 1);
|
|
2090
|
-
return `${key}(${nested})`;
|
|
2091
|
-
}
|
|
2092
|
-
return `${key}(${propType || "unknown"})`;
|
|
2093
|
-
});
|
|
2094
|
-
return `{${fields.join(", ")}}`;
|
|
2095
|
-
}
|
|
2096
|
-
if (schemaType === "array" && items) {
|
|
2097
|
-
const itemsSummary = this.extractSchemaFieldSummary(items, depth + 1);
|
|
2098
|
-
return `Array of ${itemsSummary}`;
|
|
2099
|
-
}
|
|
2100
|
-
if (schemaType) return schemaType;
|
|
2101
|
-
return Object.keys(schema).join(", ");
|
|
2102
|
-
}
|
|
2103
|
-
/**
|
|
2104
|
-
* Convenience alias for connectToMesh(), matching official documentation.
|
|
2105
|
-
*/
|
|
2106
|
-
async connect(options = {}) {
|
|
2107
|
-
return this.connectToMesh(options);
|
|
2108
|
-
}
|
|
2109
|
-
/**
|
|
2110
|
-
* Register a new Tool
|
|
2111
|
-
*/
|
|
2112
|
-
tool(name, description, shape, handler, policy) {
|
|
2113
|
-
if (this.tools.has(name)) {
|
|
2114
|
-
throw new Error(`Tool already registered: ${name}`);
|
|
2115
|
-
}
|
|
2116
|
-
const schema = z.object(shape);
|
|
2117
|
-
const generatedSchema = zodToJsonSchema(schema);
|
|
2118
|
-
let finalDescription = description;
|
|
2119
|
-
let finalHandler = handler;
|
|
2120
|
-
if (shape.payload && shape.payload instanceof z.ZodString) {
|
|
2121
|
-
const blockedKeys = this.config?.security?.forbiddenKeys || [];
|
|
2122
|
-
finalDescription += "\n\nPayload: LIOP v1 envelope (WASI sandbox). Format: @LIOP{wasi_v1,TaskName}\\n<JS code>\\n@END | Access data: env.records. Return aggregated object. Note: If dataset size < 10 (synthetic demo), Egress K-Anonymity blocks output if it has >3 keys or any array/nested object. | Full spec: resource liop://protocol/envelope-spec";
|
|
2123
|
-
if (blockedKeys.length > 0) {
|
|
2124
|
-
finalDescription += `
|
|
2125
|
-
Restricted fields: ${blockedKeys.join(", ")}.`;
|
|
2126
|
-
}
|
|
2127
|
-
if (this.activeSchema) {
|
|
2128
|
-
const schemaDigest = this.extractSchemaFieldSummary(this.activeSchema);
|
|
2129
|
-
finalDescription += `
|
|
2130
|
-
Data structure: ${schemaDigest}. Full schema: resource liop://schema/global`;
|
|
2131
|
-
}
|
|
2132
|
-
finalHandler = async (args, _extra) => {
|
|
2133
|
-
const clientId = "global_connection";
|
|
2134
|
-
const now = Date.now();
|
|
2135
|
-
const stats = this.connectionStats.get(clientId) || {
|
|
2136
|
-
failures: 0,
|
|
2137
|
-
lastAttempt: 0
|
|
2138
|
-
};
|
|
2139
|
-
if (stats.failures >= this.THROTTLE_THRESHOLD && now - stats.lastAttempt < this.THROTTLE_COOLDOWN_MS) {
|
|
2140
|
-
return {
|
|
2141
|
-
content: [
|
|
2142
|
-
{
|
|
2143
|
-
type: "text",
|
|
2144
|
-
text: "LIOP_THROTTLED: Too many violations. Cooling down for 60 seconds."
|
|
2145
|
-
}
|
|
2146
|
-
],
|
|
2147
|
-
isError: true
|
|
2148
|
-
};
|
|
2149
|
-
}
|
|
2150
|
-
const payloadValue = args.payload;
|
|
2151
|
-
const bypassCache = args.__liop_bypass_ast_cache === true;
|
|
2152
|
-
const payloadHash = crypto2.createHash("sha256").update(payloadValue).digest("hex");
|
|
2153
|
-
const logic = this.extractLogic(payloadValue);
|
|
2154
|
-
const cached = this.logicCache.get(payloadHash);
|
|
2155
|
-
if (!bypassCache && cached && now - cached.timestamp < this.CACHE_TTL_MS) {
|
|
2156
|
-
if (logic) {
|
|
2157
|
-
args.payload = logic;
|
|
2158
|
-
const preflightReason = this.runPreflightPolicy(
|
|
2159
|
-
name,
|
|
2160
|
-
logic,
|
|
2161
|
-
policy,
|
|
2162
|
-
"mcp-client"
|
|
2163
|
-
);
|
|
2164
|
-
if (preflightReason) {
|
|
2165
|
-
return {
|
|
2166
|
-
content: [{ type: "text", text: preflightReason }],
|
|
2167
|
-
isError: true
|
|
2168
|
-
};
|
|
2169
|
-
}
|
|
2170
|
-
return await this.executeInWorkerPool(args, logic, name);
|
|
2171
|
-
}
|
|
2172
|
-
}
|
|
2173
|
-
if (!logic) {
|
|
2174
|
-
stats.failures++;
|
|
2175
|
-
stats.lastAttempt = now;
|
|
2176
|
-
this.connectionStats.set(clientId, stats);
|
|
2177
|
-
return {
|
|
2178
|
-
content: [
|
|
2179
|
-
{
|
|
2180
|
-
type: "text",
|
|
2181
|
-
text: "Error: Malformed payload. Missing @LIOP boundary.\\nYou MUST wrap your logic exactly like this:\\n\\n@LIOP{wasi_v1,DynamicAudit}\\n// Your JS code here\\n@END"
|
|
2182
|
-
}
|
|
2183
|
-
],
|
|
2184
|
-
isError: true
|
|
2185
|
-
};
|
|
2186
|
-
}
|
|
2187
|
-
try {
|
|
2188
|
-
const logic2 = this.extractLogic(
|
|
2189
|
-
args.payload
|
|
2190
|
-
);
|
|
2191
|
-
args.payload = logic2;
|
|
2192
|
-
const preflightReason = this.runPreflightPolicy(
|
|
2193
|
-
name,
|
|
2194
|
-
logic2,
|
|
2195
|
-
policy,
|
|
2196
|
-
"mcp-client"
|
|
2197
|
-
);
|
|
2198
|
-
if (preflightReason) {
|
|
2199
|
-
stats.failures++;
|
|
2200
|
-
stats.lastAttempt = now;
|
|
2201
|
-
this.connectionStats.set(clientId, stats);
|
|
2202
|
-
return {
|
|
2203
|
-
content: [{ type: "text", text: preflightReason }],
|
|
2204
|
-
isError: true
|
|
2205
|
-
};
|
|
2206
|
-
}
|
|
2207
|
-
const result = await this.executeInWorkerPool(args, logic2, name);
|
|
2208
|
-
if (!result.isError) {
|
|
2209
|
-
this.connectionStats.set(clientId, {
|
|
2210
|
-
failures: 0,
|
|
2211
|
-
lastAttempt: now
|
|
2212
|
-
});
|
|
2213
|
-
this.logicCache.set(payloadHash, {
|
|
2214
|
-
hash: payloadHash,
|
|
2215
|
-
timestamp: now
|
|
2216
|
-
});
|
|
2217
|
-
} else {
|
|
2218
|
-
stats.failures++;
|
|
2219
|
-
stats.lastAttempt = now;
|
|
2220
|
-
this.connectionStats.set(clientId, stats);
|
|
2221
|
-
}
|
|
2222
|
-
return result;
|
|
2223
|
-
} catch (error) {
|
|
2224
|
-
const e = error;
|
|
2225
|
-
stats.failures++;
|
|
2226
|
-
stats.lastAttempt = now;
|
|
2227
|
-
this.connectionStats.set(clientId, stats);
|
|
2228
|
-
return {
|
|
2229
|
-
content: [
|
|
2230
|
-
{ type: "text", text: `ExecutionRuntimeException: ${e.message}` }
|
|
2231
|
-
],
|
|
2232
|
-
isError: true
|
|
2233
|
-
};
|
|
2234
|
-
}
|
|
2235
|
-
};
|
|
2236
|
-
}
|
|
2237
|
-
const inputSchema = {
|
|
2238
|
-
type: "object",
|
|
2239
|
-
properties: generatedSchema.properties || {},
|
|
2240
|
-
required: generatedSchema.required
|
|
2241
|
-
};
|
|
2242
|
-
this.tools.set(name, {
|
|
2243
|
-
tool: { name, description: finalDescription, inputSchema },
|
|
2244
|
-
handler: finalHandler,
|
|
2245
|
-
schema,
|
|
2246
|
-
policy
|
|
2247
|
-
});
|
|
2248
|
-
if (this.meshNode) {
|
|
2249
|
-
this.meshNode.announceCapability(name).catch((err) => {
|
|
2250
|
-
log.info(
|
|
2251
|
-
`[LIOP-Mesh] Failed to auto-announce tool ${name}: ${err.message}`
|
|
2252
|
-
);
|
|
2253
|
-
});
|
|
2254
|
-
}
|
|
2255
|
-
}
|
|
2256
|
-
/**
|
|
2257
|
-
* Register a dynamic prompt
|
|
2258
|
-
*/
|
|
2259
|
-
prompt(name, description, args, handler) {
|
|
2260
|
-
if (this.prompts.has(name)) {
|
|
2261
|
-
throw new Error(`Prompt already registered: ${name}`);
|
|
2262
|
-
}
|
|
2263
|
-
this.prompts.set(name, {
|
|
2264
|
-
prompt: { name, description, arguments: args },
|
|
2265
|
-
handler
|
|
2266
|
-
});
|
|
2267
|
-
}
|
|
2268
|
-
/**
|
|
2269
|
-
* Enables LIOP Zero-Shot Autonomy by registering the Blind Analyst standard prompt.
|
|
2270
|
-
*/
|
|
2271
|
-
enableZeroShotAutonomy() {
|
|
2272
|
-
this.prompt(
|
|
2273
|
-
"liop_blind_analyst",
|
|
2274
|
-
"The official Logic-Injection-on-Origin Protocol system prompt. Instructs the LLM on how to securely inject Logic-on-Origin without violating PII or safety constraints.",
|
|
2275
|
-
[],
|
|
2276
|
-
(_request) => {
|
|
2277
|
-
return {
|
|
2278
|
-
description: "LIOP Blind Analyst Instructions",
|
|
2279
|
-
messages: [
|
|
2280
|
-
{
|
|
2281
|
-
role: "user",
|
|
2282
|
-
content: {
|
|
2283
|
-
type: "text",
|
|
2284
|
-
text: `You are the "Blind Analyst" operating within the Logic-Injection-on-Origin Protocol (LIOP) ecosystem.
|
|
2285
|
-
Your objective is to perform secure Logic-on-Origin injections. You must process remote data without ever requesting its extraction.
|
|
2286
|
-
|
|
2287
|
-
INDUSTRIAL CONSTRAINTS & PROTOCOL RULES:
|
|
2288
|
-
1. DATA PRIVACY: NEVER attempt to export Personally Identifiable Information (PII). The LIOP Egress Shield will block any response containing raw IDs, names, or addresses.
|
|
2289
|
-
2. AGGREGATION FIRST & K-ANONYMITY THRESHOLDS: Always prefer returning counts, averages, or anonymized summaries.
|
|
2290
|
-
- Dataset < 10 records: Maximum of 3 scalar output fields. Nesting or arrays in output are strictly forbidden.
|
|
2291
|
-
- Dataset >= 10 records: Maximum of 10 output fields.
|
|
2292
|
-
3. LAPLACE DIFFERENTIAL PRIVACY (DP) COMPLIANCE:
|
|
2293
|
-
- Legitimate COUNT queries: To obtain EXACT, un-noised counts, you MUST name your return keys containing 'count', 'length', 'size', 'num', 'positive', 'negative', or starting with 'total_' or 'num_' (e.g. 'total_tx', 'credits_count'). This forces sensitivity=1.0, rounds values, and clamps to non-negative values.
|
|
2294
|
-
- Legitimate AVERAGE queries: Use 'avg_', '_average' or 'mean_' keys to automatically scale down Laplace noise by dividing sensitivity by the dataset size (sensitivity / n).
|
|
2295
|
-
- Legitimate SUM queries: Return keys without count/average suffixes will receive full Laplacian noise scaled by the node's global sensitivity (which can be up to 100,000 in Bank nodes to protect raw balances). Do NOT attempt to bypass this by renaming sum fields to count fields, as it violates protocol integrity.
|
|
2296
|
-
4. PAYLOAD ENCAPSULATION: Your JavaScript payloads MUST strictly adhere to the Compact Envelope. DO NOT include markdown backticks or leading text inside the 'payload' argument.
|
|
2297
|
-
Structure:
|
|
2298
|
-
@LIOP{wasi_v1,AnalysisTask}
|
|
2299
|
-
// Your JS Code Here
|
|
2300
|
-
@END
|
|
2301
|
-
5. RUNTIME SCOPE: The execution environment provides a global 'env' object. Use 'env.records' to access the target dataset.
|
|
2302
|
-
6. LOCALIZATION: Format all JSON response keys in the language used by the user in their query (e.g., use Spanish keys if the query is in Spanish).
|
|
2303
|
-
7. SCHEMA RIGIDITY: Only use fields defined in the 'Data Dictionary'. Usage of non-existent fields will trigger a sandbox runtime exception.
|
|
2304
|
-
8. SANDBOX RUNTIME: The 'Date' class/constructor is poisoned and set to undefined. Calling 'new Date()', 'Date.now()', or 'Date.parse()' will throw exceptions.
|
|
2305
|
-
Workaround: Perform chronological operations and filtering using lexicographical string comparisons on ISO 8601 date strings (e.g., 'record.date >= "2024-01-01"').
|
|
2306
|
-
Additionally, standard globals like 'eval', 'Function', 'setTimeout', 'setInterval', 'Buffer', ArrayBuffer, and TypedArrays are also undefined.${this.activeSchema ? `
|
|
2307
|
-
|
|
2308
|
-
CURRENT DATA DICTIONARY (STRICT):
|
|
2309
|
-
${JSON.stringify(this.activeSchema, null, 2)}` : ""}
|
|
2310
|
-
|
|
2311
|
-
Protocol Adherence is mandatory for successful execution.`
|
|
2312
|
-
}
|
|
2313
|
-
}
|
|
2314
|
-
]
|
|
2315
|
-
};
|
|
2316
|
-
}
|
|
2317
|
-
);
|
|
2318
|
-
}
|
|
2319
|
-
/**
|
|
2320
|
-
* Register a dynamic resource
|
|
2321
|
-
*/
|
|
2322
|
-
resource(name, uri, description, mimeType, content) {
|
|
2323
|
-
if (this.resources.has(uri)) {
|
|
2324
|
-
throw new Error(`Resource URI already registered: ${uri}`);
|
|
2325
|
-
}
|
|
2326
|
-
this.resources.set(uri, { name, uri, description, mimeType, content });
|
|
2327
|
-
}
|
|
2328
|
-
/**
|
|
2329
|
-
* Builds execution guidelines served as a resource to guide LLM code generation.
|
|
2330
|
-
*/
|
|
2331
|
-
buildExecutionGuidelines() {
|
|
2332
|
-
return [
|
|
2333
|
-
"LIOP Sandbox Execution Guidelines",
|
|
2334
|
-
"=================================",
|
|
2335
|
-
"",
|
|
2336
|
-
"1. DATE POISONING & FILTERING WORKAROUND:",
|
|
2337
|
-
" The global 'Date' class is set to undefined inside the sandbox. Calling 'new Date()', 'Date.now()', or 'Date.parse()' will throw a ReferenceError.",
|
|
2338
|
-
" - Workaround: Perform chronological filtering using lexicographical string comparisons on ISO 8601 strings.",
|
|
2339
|
-
" Example: const filtered = env.records.filter(r => r.date >= '2024-01-01' && r.date <= '2024-12-31');",
|
|
2340
|
-
"",
|
|
2341
|
-
"2. K-ANONYMITY CONSTRAINTS:",
|
|
2342
|
-
" - Datasets with LESS than 10 records: The returned object must contain at most 3 scalar fields, and must NOT contain any arrays or nested objects.",
|
|
2343
|
-
" - Datasets with 10 or MORE records: The returned object can contain up to 10 fields.",
|
|
2344
|
-
"",
|
|
2345
|
-
"3. DIFFERENTIAL PRIVACY SUFFIXES:",
|
|
2346
|
-
" To avoid Laplacian noise adding random perturbations to your counts or averages, you must name your object keys using specific terms:",
|
|
2347
|
-
" - Counts (Exact, no noise): Key names must contain 'count', 'length', 'size', 'num', 'positive', 'negative', or start with 'total_' or 'num_'.",
|
|
2348
|
-
" - Averages (Reduced noise): Key names must contain 'avg', 'mean', or 'average'.",
|
|
2349
|
-
" - Sums/Other: Will receive full Laplace noise.",
|
|
2350
|
-
"",
|
|
2351
|
-
"4. GENERAL RESTRICTIONS:",
|
|
2352
|
-
" - Do not use 'eval', 'Function', 'setTimeout', 'setInterval', 'Buffer', 'ArrayBuffer', or TypedArrays.",
|
|
2353
|
-
" - Do not attempt to modify prototypes (Object.prototype, Array.prototype)."
|
|
2354
|
-
].join("\n");
|
|
2355
|
-
}
|
|
2356
|
-
/**
|
|
2357
|
-
* Broadcasts the Data Dictionary to the LLM prior to code injection.
|
|
2358
|
-
*/
|
|
2359
|
-
dataDictionary(schema, name = "Global Medical Data Dictionary", uri = "liop://schema/global", description = "Exposes the internal database schema for Zero-Shot Autonomy planning") {
|
|
2360
|
-
schema.$comment = "LIOP DIRECTIVES: 1. Date is undefined (Date.now(), new Date() throw). Workaround: use lexicographical string comparison on ISO 8601 string dates (e.g. record.date >= '2024-01-01'). 2. Small datasets (<10 records) limit outputs to max 3 scalar keys with NO nesting. 3. DP counts must contain count/length/size/num/total_ prefix/suffix.";
|
|
2361
|
-
this.activeSchema = schema;
|
|
2362
|
-
const schemaDigest = this.extractSchemaFieldSummary(schema);
|
|
2363
|
-
for (const [toolName, entry] of this.tools.entries()) {
|
|
2364
|
-
if (entry.schema.shape.payload && entry.schema.shape.payload instanceof z.ZodString && entry.tool.description && !entry.tool.description.includes("Data structure:")) {
|
|
2365
|
-
entry.tool.description += `
|
|
2366
|
-
Data structure: ${schemaDigest}. Full schema: resource ${uri}. Guidelines: resource liop://schema/guidelines`;
|
|
2367
|
-
this.tools.set(toolName, entry);
|
|
2368
|
-
}
|
|
2369
|
-
}
|
|
2370
|
-
if (!this.resources.has("liop://schema/guidelines")) {
|
|
2371
|
-
this.resource(
|
|
2372
|
-
"LIOP Execution Guidelines",
|
|
2373
|
-
"liop://schema/guidelines",
|
|
2374
|
-
"Directives for generating compliant JavaScript code for the LIOP Sandbox runtime",
|
|
2375
|
-
"text/plain",
|
|
2376
|
-
() => Promise.resolve(this.buildExecutionGuidelines())
|
|
2377
|
-
);
|
|
2378
|
-
}
|
|
2379
|
-
this.resource(
|
|
2380
|
-
name,
|
|
2381
|
-
uri,
|
|
2382
|
-
description,
|
|
2383
|
-
"application/json",
|
|
2384
|
-
JSON.stringify(schema, null, 2)
|
|
2385
|
-
);
|
|
2386
|
-
}
|
|
2387
|
-
/**
|
|
2388
|
-
* Manually invalidates the AST Logic Cache (e.g. for Zero-Day patches).
|
|
2389
|
-
*/
|
|
2390
|
-
clearAstCache() {
|
|
2391
|
-
this.logicCache.clear();
|
|
2392
|
-
log.info("[LIOP-SDK] AST Security Cache cleared by Admin.");
|
|
2393
|
-
}
|
|
2394
|
-
/**
|
|
2395
|
-
* Sliding window rate limiter for tool call frequency.
|
|
2396
|
-
* Prevents micro-query exfiltration attacks where an attacker
|
|
2397
|
-
* makes hundreds of individually-legitimate calls to reconstruct
|
|
2398
|
-
* the full dataset field by field. (OWASP A01)
|
|
2399
|
-
*/
|
|
2400
|
-
checkToolCallRateLimit(toolName) {
|
|
2401
|
-
const now = Date.now();
|
|
2402
|
-
const windowMs = this.toolCallWindowMs;
|
|
2403
|
-
const maxPerWindow = this.toolCallMaxPerWindow;
|
|
2404
|
-
const window = this.toolCallWindows.get(toolName) || [];
|
|
2405
|
-
const active = window.filter((t) => now - t < windowMs);
|
|
2406
|
-
if (active.length >= maxPerWindow) {
|
|
2407
|
-
const retryAfterSec = Math.ceil((active[0] + windowMs - now) / 1e3);
|
|
2408
|
-
return {
|
|
2409
|
-
content: [
|
|
2410
|
-
{
|
|
2411
|
-
type: "text",
|
|
2412
|
-
text: `LIOP_RATE_LIMITED: Too many calls to ${toolName}. Max ${maxPerWindow} per ${windowMs / 1e3}s window. Retry after ${retryAfterSec}s.`
|
|
2413
|
-
}
|
|
2414
|
-
],
|
|
2415
|
-
isError: true
|
|
2416
|
-
};
|
|
2417
|
-
}
|
|
2418
|
-
active.push(now);
|
|
2419
|
-
this.toolCallWindows.set(toolName, active);
|
|
2420
|
-
return null;
|
|
2421
|
-
}
|
|
2422
|
-
/**
|
|
2423
|
-
* Global cross-tool rate limiter.
|
|
2424
|
-
* Prevents attackers from distributing micro-queries across multiple tools
|
|
2425
|
-
* to evade per-tool rate limits. (OWASP A01)
|
|
2426
|
-
*/
|
|
2427
|
-
checkGlobalRateLimit() {
|
|
2428
|
-
const now = Date.now();
|
|
2429
|
-
const windowMs = this.toolCallWindowMs;
|
|
2430
|
-
const maxGlobal = this.globalCallMaxPerWindow;
|
|
2431
|
-
this.globalCallWindow = this.globalCallWindow.filter(
|
|
2432
|
-
(t) => now - t < windowMs
|
|
2433
|
-
);
|
|
2434
|
-
if (this.globalCallWindow.length >= maxGlobal) {
|
|
2435
|
-
const retryAfterSec = Math.ceil(
|
|
2436
|
-
(this.globalCallWindow[0] + windowMs - now) / 1e3
|
|
2437
|
-
);
|
|
2438
|
-
return {
|
|
2439
|
-
content: [
|
|
2440
|
-
{
|
|
2441
|
-
type: "text",
|
|
2442
|
-
text: `LIOP_RATE_LIMITED: Global call limit exceeded. Max ${maxGlobal} total calls per ${windowMs / 1e3}s window. Retry after ${retryAfterSec}s.`
|
|
2443
|
-
}
|
|
2444
|
-
],
|
|
2445
|
-
isError: true
|
|
2446
|
-
};
|
|
2447
|
-
}
|
|
2448
|
-
this.globalCallWindow.push(now);
|
|
2449
|
-
return null;
|
|
2450
|
-
}
|
|
2451
|
-
/**
|
|
2452
|
-
* Emulates calling a tool (used locally or via LIOPMcpBridge)
|
|
2453
|
-
*/
|
|
2454
|
-
async callTool(request, clientId = "local-client") {
|
|
2455
|
-
const entry = this.tools.get(request.name);
|
|
2456
|
-
if (!entry) {
|
|
2457
|
-
throw new Error(`Tool not found: ${request.name}`);
|
|
2458
|
-
}
|
|
2459
|
-
const globalLimitResult = this.checkGlobalRateLimit();
|
|
2460
|
-
if (globalLimitResult) return globalLimitResult;
|
|
2461
|
-
const rateLimitResult = this.checkToolCallRateLimit(request.name);
|
|
2462
|
-
if (rateLimitResult) return rateLimitResult;
|
|
2463
|
-
try {
|
|
2464
|
-
const parsedArgs = entry.schema.parse(request.arguments || {});
|
|
2465
|
-
if (request.arguments?.__liop_bypass_ast_cache === true) {
|
|
2466
|
-
parsedArgs.__liop_bypass_ast_cache = true;
|
|
2467
|
-
}
|
|
2468
|
-
if (parsedArgs && typeof parsedArgs.payload === "string") {
|
|
2469
|
-
const payload = parsedArgs.payload;
|
|
2470
|
-
const logic = this.extractLogic(payload);
|
|
2471
|
-
if (logic) {
|
|
2472
|
-
const preflightReason = this.runPreflightPolicy(
|
|
2473
|
-
request.name,
|
|
2474
|
-
logic,
|
|
2475
|
-
entry.policy,
|
|
2476
|
-
clientId
|
|
2477
|
-
);
|
|
2478
|
-
if (preflightReason) {
|
|
2479
|
-
return {
|
|
2480
|
-
content: [{ type: "text", text: preflightReason }],
|
|
2481
|
-
isError: true
|
|
2482
|
-
};
|
|
2483
|
-
}
|
|
2484
|
-
parsedArgs.payload = logic;
|
|
2485
|
-
return await this.executeInWorkerPool(
|
|
2486
|
-
parsedArgs,
|
|
2487
|
-
logic,
|
|
2488
|
-
request.name
|
|
2489
|
-
);
|
|
2490
|
-
}
|
|
2491
|
-
}
|
|
2492
|
-
const result = await entry.handler(parsedArgs, {});
|
|
2493
|
-
return result;
|
|
2494
|
-
} catch (error) {
|
|
2495
|
-
const e = error;
|
|
2496
|
-
if (e instanceof z.ZodError) {
|
|
2497
|
-
return {
|
|
2498
|
-
content: [{ type: "text", text: `Validation Error: ${e.message}` }],
|
|
2499
|
-
isError: true
|
|
2500
|
-
};
|
|
2501
|
-
}
|
|
2502
|
-
return {
|
|
2503
|
-
content: [
|
|
2504
|
-
{ type: "text", text: `Internal Execution Error: ${e.message}` }
|
|
2505
|
-
],
|
|
2506
|
-
isError: true
|
|
2507
|
-
};
|
|
2508
|
-
}
|
|
2509
|
-
}
|
|
2510
|
-
/**
|
|
2511
|
-
* Retrieves registered tools
|
|
2512
|
-
*/
|
|
2513
|
-
listTools() {
|
|
2514
|
-
return Array.from(this.tools.values()).map((t) => t.tool);
|
|
2515
|
-
}
|
|
2516
|
-
/**
|
|
2517
|
-
* Retrieves registered prompts
|
|
2518
|
-
*/
|
|
2519
|
-
listPrompts() {
|
|
2520
|
-
return Array.from(this.prompts.values()).map((p) => p.prompt);
|
|
2521
|
-
}
|
|
2522
|
-
/**
|
|
2523
|
-
* Gets a specific prompt by name
|
|
2524
|
-
*/
|
|
2525
|
-
async getPrompt(request) {
|
|
2526
|
-
const entry = this.prompts.get(request.name);
|
|
2527
|
-
if (!entry) {
|
|
2528
|
-
throw new Error(`Prompt not found: ${request.name}`);
|
|
2529
|
-
}
|
|
2530
|
-
return await entry.handler(request);
|
|
2531
|
-
}
|
|
2532
|
-
/**
|
|
2533
|
-
* Retrieves registered resources
|
|
2534
|
-
*/
|
|
2535
|
-
listResources() {
|
|
2536
|
-
return Array.from(this.resources.values());
|
|
2537
|
-
}
|
|
2538
|
-
/**
|
|
2539
|
-
* Reads a specific resource by URI
|
|
2540
|
-
*/
|
|
2541
|
-
async readResource(uri) {
|
|
2542
|
-
const resource = this.resources.get(uri);
|
|
2543
|
-
if (!resource) {
|
|
2544
|
-
throw new Error(`Resource not found: ${uri}`);
|
|
2545
|
-
}
|
|
2546
|
-
let text = "No description provided";
|
|
2547
|
-
if (typeof resource.content === "function") {
|
|
2548
|
-
text = await resource.content();
|
|
2549
|
-
} else if (typeof resource.content === "string") {
|
|
2550
|
-
text = resource.content;
|
|
2551
|
-
} else if (resource.description) {
|
|
2552
|
-
text = resource.description;
|
|
2553
|
-
}
|
|
2554
|
-
return {
|
|
2555
|
-
contents: [
|
|
2556
|
-
{
|
|
2557
|
-
uri: resource.uri,
|
|
2558
|
-
mimeType: resource.mimeType || "text/plain",
|
|
2559
|
-
text
|
|
2560
|
-
}
|
|
2561
|
-
]
|
|
2562
|
-
};
|
|
2563
|
-
}
|
|
2564
|
-
getServerInfo() {
|
|
2565
|
-
return this.serverInfo;
|
|
2566
|
-
}
|
|
2567
|
-
getMeshNode() {
|
|
2568
|
-
return this.meshNode;
|
|
2569
|
-
}
|
|
2570
|
-
/**
|
|
2571
|
-
* Injects data into the secure sandbox context for Logic-on-Origin tools.
|
|
2572
|
-
*/
|
|
2573
|
-
setSandboxData(records) {
|
|
2574
|
-
this.sandboxRecords = records;
|
|
2575
|
-
}
|
|
2576
|
-
getBoundPort() {
|
|
2577
|
-
return this.boundPort;
|
|
2578
|
-
}
|
|
2579
|
-
/**
|
|
2580
|
-
* Connects to the libp2p Kademlia DHT and announces capabilities.
|
|
2581
|
-
* Boots the gRPC server for secure Logic-on-Origin.
|
|
2582
|
-
*/
|
|
2583
|
-
async connectToMesh(options = {}) {
|
|
2584
|
-
const envPort = process.env.LIOP_GRPC_PORT ? Number.parseInt(process.env.LIOP_GRPC_PORT, 10) : void 0;
|
|
2585
|
-
const port = options.port ?? envPort ?? 50051;
|
|
2586
|
-
const TOKEN_SLUG_PATTERN = /^[A-Z][A-Z0-9_]*$/;
|
|
2587
|
-
if (this.config?.tokenSlug && !TOKEN_SLUG_PATTERN.test(this.config.tokenSlug)) {
|
|
2588
|
-
throw new Error(
|
|
2589
|
-
`Invalid tokenSlug "${this.config.tokenSlug}". Must match SCREAMING_SNAKE_CASE: /^[A-Z][A-Z0-9_]*$/ (e.g., "BANK", "VAULT", "HFT_ORACLE").`
|
|
2590
|
-
);
|
|
2591
|
-
}
|
|
2592
|
-
this.meshNode = new MeshNode(options.meshConfig);
|
|
2593
|
-
await this.meshNode.start();
|
|
2594
|
-
const meshNodeRef = this.meshNode;
|
|
2595
|
-
this.meshNode.registerManifestHandler(() => {
|
|
2596
|
-
const tools = this.listTools().map((t) => ({
|
|
2597
|
-
name: t.name,
|
|
2598
|
-
description: t.description,
|
|
2599
|
-
inputSchema: t.inputSchema
|
|
2600
|
-
}));
|
|
2601
|
-
const resources = Array.from(this.resources.values()).map((r) => ({
|
|
2602
|
-
name: r.name,
|
|
2603
|
-
uri: r.uri,
|
|
2604
|
-
description: r.description,
|
|
2605
|
-
mimeType: r.mimeType,
|
|
2606
|
-
text: typeof r.content === "string" ? r.content : r.description
|
|
2607
|
-
}));
|
|
2608
|
-
return {
|
|
2609
|
-
peerId: meshNodeRef.getPeerId(),
|
|
2610
|
-
grpcPort: port,
|
|
2611
|
-
tools,
|
|
2612
|
-
resources,
|
|
2613
|
-
serverInfo: this.serverInfo,
|
|
2614
|
-
authRequired: this.jwtValidator !== void 0,
|
|
2615
|
-
tokenSlug: this.config?.tokenSlug,
|
|
2616
|
-
taxonomy: this.config?.taxonomy ? {
|
|
2617
|
-
domain: this.config.taxonomy.domain || "Unknown Domain",
|
|
2618
|
-
clearanceTier: this.config.taxonomy.clearanceTier ?? 0,
|
|
2619
|
-
executionTypes: this.config.taxonomy.executionTypes || []
|
|
2620
|
-
} : void 0
|
|
2621
|
-
};
|
|
2622
|
-
});
|
|
2623
|
-
for (const tool of this.listTools()) {
|
|
2624
|
-
await this.meshNode.announceCapability(tool.name).catch(log.info);
|
|
2625
|
-
}
|
|
2626
|
-
await this.meshNode.announceManifest().catch(log.info);
|
|
2627
|
-
this.rpcServer = new LiopRpcServer();
|
|
2628
|
-
this.rpcServer.addService({
|
|
2629
|
-
negotiateIntent: (call, callback) => {
|
|
2630
|
-
const request = call.request;
|
|
2631
|
-
log.info(
|
|
2632
|
-
`[LIOP-RPC] Negotiating intent for capability: ${request.capability_hash}`
|
|
2633
|
-
);
|
|
2634
|
-
if (this.jwtValidator) {
|
|
2635
|
-
const authHeader = call.metadata.get("authorization")[0];
|
|
2636
|
-
if (!authHeader) {
|
|
2637
|
-
callback({
|
|
2638
|
-
code: grpc2.status.UNAUTHENTICATED,
|
|
2639
|
-
details: "Missing Authorization header in gRPC metadata"
|
|
2640
|
-
});
|
|
2641
|
-
return;
|
|
2642
|
-
}
|
|
2643
|
-
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
|
|
2644
|
-
const tokenHash = crypto2.createHash("sha256").update(token).digest("hex").toLowerCase();
|
|
2645
|
-
this.loadRevocationList();
|
|
2646
|
-
if (this.revokedTokenHashes.has(tokenHash)) {
|
|
2647
|
-
callback({
|
|
2648
|
-
code: grpc2.status.UNAUTHENTICATED,
|
|
2649
|
-
details: "Token has been revoked by the resource owner"
|
|
2650
|
-
});
|
|
2651
|
-
return;
|
|
2652
|
-
}
|
|
2653
|
-
const localTestToken = this.config?.auth?.localTestToken;
|
|
2654
|
-
const isTestTokenPattern = /^[a-zA-Z0-9_-]+-local-test-token$/;
|
|
2655
|
-
if (localTestToken) {
|
|
2656
|
-
if (token === localTestToken) {
|
|
2657
|
-
log.info(
|
|
2658
|
-
`[LIOP-RPC] Bypass authentication for matching localTestToken: ${localTestToken}`
|
|
2659
|
-
);
|
|
2660
|
-
import('./kyber-3ULIJSE3.js').then(
|
|
2661
|
-
async ({ Kyber768Wrapper }) => {
|
|
2662
|
-
const { publicKey, secretKey } = await Kyber768Wrapper.generateKeyPair();
|
|
2663
|
-
const sessionToken = crypto2.randomUUID();
|
|
2664
|
-
this.sessions.set(sessionToken, {
|
|
2665
|
-
capability_hash: request.capability_hash,
|
|
2666
|
-
kyber_sk: secretKey,
|
|
2667
|
-
agent_did: request.agent_did,
|
|
2668
|
-
tokenHash
|
|
2669
|
-
});
|
|
2670
|
-
callback(null, {
|
|
2671
|
-
accepted: true,
|
|
2672
|
-
session_token: sessionToken,
|
|
2673
|
-
error_message: "",
|
|
2674
|
-
kyber_public_key: publicKey
|
|
2675
|
-
});
|
|
2676
|
-
}
|
|
2677
|
-
);
|
|
2678
|
-
return;
|
|
2679
|
-
}
|
|
2680
|
-
callback({
|
|
2681
|
-
code: grpc2.status.PERMISSION_DENIED,
|
|
2682
|
-
details: token && isTestTokenPattern.test(token) ? "Pre-shared local test token is invalid for this resource domain (segregation violation)." : "Access Denied: This restricted node requires its specific static access token."
|
|
2683
|
-
});
|
|
2684
|
-
return;
|
|
2685
|
-
}
|
|
2686
|
-
this.jwtValidator.validate(token).then((authInfo) => {
|
|
2687
|
-
const authResult = authorizeRequest("tools/call", authInfo);
|
|
2688
|
-
if (!authResult.allowed) {
|
|
2689
|
-
callback({
|
|
2690
|
-
code: grpc2.status.PERMISSION_DENIED,
|
|
2691
|
-
details: authResult.reason || "Access Denied"
|
|
2692
|
-
});
|
|
2693
|
-
return;
|
|
2694
|
-
}
|
|
2695
|
-
import('./kyber-3ULIJSE3.js').then(
|
|
2696
|
-
async ({ Kyber768Wrapper }) => {
|
|
2697
|
-
const { publicKey, secretKey } = await Kyber768Wrapper.generateKeyPair();
|
|
2698
|
-
const sessionToken = crypto2.randomUUID();
|
|
2699
|
-
this.sessions.set(sessionToken, {
|
|
2700
|
-
capability_hash: request.capability_hash,
|
|
2701
|
-
kyber_sk: secretKey,
|
|
2702
|
-
agent_did: request.agent_did,
|
|
2703
|
-
tokenHash
|
|
2704
|
-
});
|
|
2705
|
-
callback(null, {
|
|
2706
|
-
accepted: true,
|
|
2707
|
-
session_token: sessionToken,
|
|
2708
|
-
error_message: "",
|
|
2709
|
-
kyber_public_key: publicKey
|
|
2710
|
-
});
|
|
2711
|
-
}
|
|
2712
|
-
);
|
|
2713
|
-
}).catch((err) => {
|
|
2714
|
-
callback({
|
|
2715
|
-
code: grpc2.status.UNAUTHENTICATED,
|
|
2716
|
-
details: `Invalid JWT token: ${err.message}`
|
|
2717
|
-
});
|
|
2718
|
-
});
|
|
2719
|
-
} else {
|
|
2720
|
-
import('./kyber-3ULIJSE3.js').then(async ({ Kyber768Wrapper }) => {
|
|
2721
|
-
const { publicKey, secretKey } = await Kyber768Wrapper.generateKeyPair();
|
|
2722
|
-
const sessionToken = crypto2.randomUUID();
|
|
2723
|
-
this.sessions.set(sessionToken, {
|
|
2724
|
-
capability_hash: request.capability_hash,
|
|
2725
|
-
kyber_sk: secretKey,
|
|
2726
|
-
agent_did: request.agent_did
|
|
2727
|
-
});
|
|
2728
|
-
callback(null, {
|
|
2729
|
-
accepted: true,
|
|
2730
|
-
session_token: sessionToken,
|
|
2731
|
-
error_message: "",
|
|
2732
|
-
kyber_public_key: publicKey
|
|
2733
|
-
});
|
|
2734
|
-
});
|
|
2735
|
-
}
|
|
2736
|
-
},
|
|
2737
|
-
executeLogic: async (call) => {
|
|
2738
|
-
const request = call.request;
|
|
2739
|
-
log.info(
|
|
2740
|
-
`[LIOP-RPC] Executing Logic-on-Origin for session: ${request.session_token}`
|
|
2741
|
-
);
|
|
2742
|
-
const proceed = async () => {
|
|
2743
|
-
const session = this.sessions.get(request.session_token);
|
|
2744
|
-
if (!session) {
|
|
2745
|
-
call.emit("error", {
|
|
2746
|
-
code: grpc2.status.UNAUTHENTICATED,
|
|
2747
|
-
details: "Invalid session token"
|
|
2748
|
-
});
|
|
2749
|
-
return;
|
|
2750
|
-
}
|
|
2751
|
-
if (session.tokenHash) {
|
|
2752
|
-
this.loadRevocationList();
|
|
2753
|
-
if (this.revokedTokenHashes.has(session.tokenHash)) {
|
|
2754
|
-
call.emit("error", {
|
|
2755
|
-
code: grpc2.status.UNAUTHENTICATED,
|
|
2756
|
-
details: "Token has been revoked by the resource owner"
|
|
2757
|
-
});
|
|
2758
|
-
return;
|
|
2759
|
-
}
|
|
2760
|
-
}
|
|
2761
|
-
const toolName = session.capability_hash;
|
|
2762
|
-
const toolDef = toolName ? this.tools.get(toolName) : void 0;
|
|
2763
|
-
const toolPolicy = toolDef?.policy;
|
|
2764
|
-
try {
|
|
2765
|
-
const kem = await createMlKem768();
|
|
2766
|
-
const sharedSecret = kem.decap(
|
|
2767
|
-
new Uint8Array(request.pqc_ciphertext),
|
|
2768
|
-
session.kyber_sk
|
|
2769
|
-
);
|
|
2770
|
-
const aesKey = Buffer.from(sharedSecret);
|
|
2771
|
-
const wasmBuffer = Buffer.from(request.wasm_binary);
|
|
2772
|
-
const authTag = wasmBuffer.subarray(-16);
|
|
2773
|
-
const encryptedData = wasmBuffer.subarray(0, -16);
|
|
2774
|
-
const decipher = crypto2.createDecipheriv(
|
|
2775
|
-
"aes-256-gcm",
|
|
2776
|
-
aesKey,
|
|
2777
|
-
Buffer.from(request.aes_nonce || new Uint8Array(12))
|
|
2778
|
-
);
|
|
2779
|
-
decipher.setAuthTag(authTag);
|
|
2780
|
-
let decrypted = decipher.update(encryptedData);
|
|
2781
|
-
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
2782
|
-
const decryptedPayload = decrypted.toString("utf-8");
|
|
2783
|
-
const logic = this.extractLogic(decryptedPayload) || decryptedPayload.trim();
|
|
2784
|
-
const preflightReason = this.runPreflightPolicy(
|
|
2785
|
-
toolName || "unknown_tool",
|
|
2786
|
-
logic,
|
|
2787
|
-
toolPolicy,
|
|
2788
|
-
session.agent_did || request.session_token
|
|
2789
|
-
);
|
|
2790
|
-
if (preflightReason) {
|
|
2791
|
-
log.info(`[LIOP-RPC] Preflight blocked: ${preflightReason}`);
|
|
2792
|
-
const errorResponse = {
|
|
2793
|
-
semantic_evidence: preflightReason,
|
|
2794
|
-
cryptographic_proof: Buffer.from(""),
|
|
2795
|
-
zk_receipt: Buffer.from(""),
|
|
2796
|
-
is_error: true
|
|
2797
|
-
};
|
|
2798
|
-
call.write(errorResponse, () => {
|
|
2799
|
-
call.end();
|
|
2800
|
-
});
|
|
2801
|
-
return;
|
|
2802
|
-
}
|
|
2803
|
-
} catch (decryptionError) {
|
|
2804
|
-
const errorMsg = decryptionError instanceof Error ? decryptionError.message : String(decryptionError);
|
|
2805
|
-
log.error(`[LIOP-RPC] Preflight decryption failed: ${errorMsg}`);
|
|
2806
|
-
call.emit("error", {
|
|
2807
|
-
code: grpc2.status.INTERNAL,
|
|
2808
|
-
details: `Preflight logic analysis failed: ${errorMsg}`
|
|
2809
|
-
});
|
|
2810
|
-
return;
|
|
2811
|
-
}
|
|
2812
|
-
try {
|
|
2813
|
-
const dpConfig = toolPolicy ? {
|
|
2814
|
-
epsilon: toolPolicy.dpEpsilon ?? 1,
|
|
2815
|
-
sensitivity: toolPolicy.dpSensitivity ?? 1,
|
|
2816
|
-
smallDatasetThreshold: toolPolicy.dpSmallDatasetThreshold ?? 50
|
|
2817
|
-
} : void 0;
|
|
2818
|
-
const workerResponse = await this.workerPool.run({
|
|
2819
|
-
ciphertext: request.pqc_ciphertext,
|
|
2820
|
-
secretKeyObj: Array.from(session.kyber_sk),
|
|
2821
|
-
wasmBinary: request.wasm_binary,
|
|
2822
|
-
inputs: request.inputs,
|
|
2823
|
-
aesNonce: request.aes_nonce,
|
|
2824
|
-
records: this.sandboxRecords,
|
|
2825
|
-
sessionToken: request.session_token,
|
|
2826
|
-
isEncrypted: true,
|
|
2827
|
-
dpConfig
|
|
2828
|
-
// Apply DP noise inside worker before ZK-Receipt commitment
|
|
2829
|
-
});
|
|
2830
|
-
const sanitizedWorkerOutput = sanitizeOutput(workerResponse.output);
|
|
2831
|
-
let finalOutput;
|
|
2832
|
-
let validationOutput = sanitizedWorkerOutput;
|
|
2833
|
-
try {
|
|
2834
|
-
finalOutput = typeof sanitizedWorkerOutput === "string" ? sanitizedWorkerOutput : JSON.stringify(sanitizedWorkerOutput);
|
|
2835
|
-
const decoded = JSON.parse(finalOutput);
|
|
2836
|
-
if (decoded.__liop_proxy_tool) {
|
|
2837
|
-
log.info(
|
|
2838
|
-
`[LIOP-RPC] Executing Proxied Tool: ${decoded.__liop_proxy_tool}`
|
|
2839
|
-
);
|
|
2840
|
-
const clientId = session.agent_did || "unknown-client";
|
|
2841
|
-
const toolResult = await this.callTool(
|
|
2842
|
-
{
|
|
2843
|
-
name: decoded.__liop_proxy_tool,
|
|
2844
|
-
arguments: decoded.__liop_proxy_args || {}
|
|
2845
|
-
},
|
|
2846
|
-
clientId
|
|
2847
|
-
);
|
|
2848
|
-
if (toolResult.isError) {
|
|
2849
|
-
log.info(
|
|
2850
|
-
`[LIOP-RPC] Proxy tool execution failed: ${toolResult.content[0].text}`
|
|
2851
|
-
);
|
|
2852
|
-
const errorResponse = {
|
|
2853
|
-
semantic_evidence: toolResult.content[0].text || "Unknown error",
|
|
2854
|
-
cryptographic_proof: Buffer.from(""),
|
|
2855
|
-
zk_receipt: Buffer.from(""),
|
|
2856
|
-
is_error: true
|
|
2857
|
-
};
|
|
2858
|
-
call.write(errorResponse, () => {
|
|
2859
|
-
call.end();
|
|
2860
|
-
});
|
|
2861
|
-
return;
|
|
2862
|
-
}
|
|
2863
|
-
const sanitizedToolResult = sanitizeOutput(toolResult);
|
|
2864
|
-
finalOutput = JSON.stringify(sanitizedToolResult);
|
|
2865
|
-
validationOutput = this.unwrapForAggregationPolicyScan(sanitizedToolResult);
|
|
2866
|
-
}
|
|
2867
|
-
} catch {
|
|
2868
|
-
finalOutput = String(sanitizedWorkerOutput);
|
|
2869
|
-
}
|
|
2870
|
-
const policyViolation = this.validateOutputPolicy(
|
|
2871
|
-
toolName || "unknown_tool",
|
|
2872
|
-
validationOutput,
|
|
2873
|
-
toolPolicy
|
|
2874
|
-
);
|
|
2875
|
-
if (policyViolation) {
|
|
2876
|
-
log.info(
|
|
2877
|
-
`[LIOP-RPC] Output policy blocked for ${toolName || "unknown_tool"}: ${policyViolation}`
|
|
2878
|
-
);
|
|
2879
|
-
const isDev = process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test" || process.env.LIOP_SEC_VERBOSE === "1";
|
|
2880
|
-
const errorMessage = isDev ? policyViolation : "[LIOP] Egress Security Violation. Output blocked due to policy enforcement.";
|
|
2881
|
-
const errorResponse = {
|
|
2882
|
-
semantic_evidence: errorMessage,
|
|
2883
|
-
cryptographic_proof: Buffer.from(""),
|
|
2884
|
-
zk_receipt: Buffer.from(""),
|
|
2885
|
-
is_error: true
|
|
2886
|
-
};
|
|
2887
|
-
call.write(errorResponse, () => {
|
|
2888
|
-
call.end();
|
|
2889
|
-
});
|
|
2890
|
-
return;
|
|
2891
|
-
}
|
|
2892
|
-
const response = {
|
|
2893
|
-
semantic_evidence: finalOutput,
|
|
2894
|
-
cryptographic_proof: Buffer.from(
|
|
2895
|
-
workerResponse.image_id || "",
|
|
2896
|
-
"hex"
|
|
2897
|
-
),
|
|
2898
|
-
zk_receipt: workerResponse.zk_receipt ? Buffer.from(workerResponse.zk_receipt, "base64") : Buffer.from(""),
|
|
2899
|
-
is_error: false
|
|
2900
|
-
};
|
|
2901
|
-
const violation = await this.piiScanner.scan(validationOutput);
|
|
2902
|
-
const aggregationViolation = this.violatesAggregationFirstPolicy(
|
|
2903
|
-
this.unwrapForAggregationPolicyScan(validationOutput),
|
|
2904
|
-
toolPolicy?.enforceAggregationFirst,
|
|
2905
|
-
this.sandboxRecords?.length
|
|
2906
|
-
);
|
|
2907
|
-
if (violation || aggregationViolation) {
|
|
2908
|
-
const internalReason = violation || "Aggregation-First Policy Violation";
|
|
2909
|
-
log.info(
|
|
2910
|
-
`[LIOP-RPC] Secure egress blocked in gRPC stream: ${internalReason}`
|
|
2911
|
-
);
|
|
2912
|
-
response.semantic_evidence = "[LIOP] Egress Security Violation. Output blocked due to policy enforcement.";
|
|
2913
|
-
response.is_error = true;
|
|
2914
|
-
}
|
|
2915
|
-
call.write(response, () => {
|
|
2916
|
-
call.end();
|
|
2917
|
-
});
|
|
2918
|
-
} catch (error) {
|
|
2919
|
-
const e = error;
|
|
2920
|
-
const isDev = process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
|
|
2921
|
-
const detail = e.message || String(error);
|
|
2922
|
-
log.error(`[LIOP-RPC] Execution Error: ${detail}`);
|
|
2923
|
-
const errorMessage = isDev ? `Execution Error: ${detail}` : "[LIOP] Execution Failed. The injected logic violated runtime constraints or encountered a fatal error.";
|
|
2924
|
-
const errorResponse = {
|
|
2925
|
-
semantic_evidence: errorMessage,
|
|
2926
|
-
cryptographic_proof: Buffer.from(""),
|
|
2927
|
-
zk_receipt: Buffer.from(""),
|
|
2928
|
-
is_error: true
|
|
2929
|
-
};
|
|
2930
|
-
try {
|
|
2931
|
-
call.write(errorResponse, () => {
|
|
2932
|
-
call.end();
|
|
2933
|
-
});
|
|
2934
|
-
} catch (_writeErr) {
|
|
2935
|
-
call.end();
|
|
2936
|
-
}
|
|
2937
|
-
}
|
|
2938
|
-
};
|
|
2939
|
-
if (this.jwtValidator) {
|
|
2940
|
-
const authHeader = call.metadata.get("authorization")[0];
|
|
2941
|
-
if (!authHeader) {
|
|
2942
|
-
call.emit("error", {
|
|
2943
|
-
code: grpc2.status.UNAUTHENTICATED,
|
|
2944
|
-
details: "Missing Authorization header in gRPC metadata"
|
|
2945
|
-
});
|
|
2946
|
-
return;
|
|
2947
|
-
}
|
|
2948
|
-
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
|
|
2949
|
-
const tokenHash = crypto2.createHash("sha256").update(token).digest("hex").toLowerCase();
|
|
2950
|
-
this.loadRevocationList();
|
|
2951
|
-
if (this.revokedTokenHashes.has(tokenHash)) {
|
|
2952
|
-
call.emit("error", {
|
|
2953
|
-
code: grpc2.status.UNAUTHENTICATED,
|
|
2954
|
-
details: "Token has been revoked by the resource owner"
|
|
2955
|
-
});
|
|
2956
|
-
return;
|
|
2957
|
-
}
|
|
2958
|
-
const localTestToken = this.config?.auth?.localTestToken;
|
|
2959
|
-
const isTestTokenPattern = /^[a-zA-Z0-9_-]+-local-test-token$/;
|
|
2960
|
-
if (localTestToken) {
|
|
2961
|
-
if (token === localTestToken) {
|
|
2962
|
-
log.info(
|
|
2963
|
-
`[LIOP-RPC] Bypass authentication in executeLogic for matching localTestToken: ${localTestToken}`
|
|
2964
|
-
);
|
|
2965
|
-
await proceed();
|
|
2966
|
-
return;
|
|
2967
|
-
}
|
|
2968
|
-
call.emit("error", {
|
|
2969
|
-
code: grpc2.status.PERMISSION_DENIED,
|
|
2970
|
-
details: token && isTestTokenPattern.test(token) ? "Pre-shared local test token is invalid for this resource domain (segregation violation)." : "Access Denied: This restricted node requires its specific static access token."
|
|
2971
|
-
});
|
|
2972
|
-
return;
|
|
2973
|
-
}
|
|
2974
|
-
try {
|
|
2975
|
-
const authInfo = await this.jwtValidator.validate(token);
|
|
2976
|
-
const authResult = authorizeRequest("tools/call", authInfo);
|
|
2977
|
-
if (!authResult.allowed) {
|
|
2978
|
-
call.emit("error", {
|
|
2979
|
-
code: grpc2.status.PERMISSION_DENIED,
|
|
2980
|
-
details: authResult.reason || "Access Denied"
|
|
2981
|
-
});
|
|
2982
|
-
return;
|
|
2983
|
-
}
|
|
2984
|
-
await proceed();
|
|
2985
|
-
} catch (err) {
|
|
2986
|
-
call.emit("error", {
|
|
2987
|
-
code: grpc2.status.UNAUTHENTICATED,
|
|
2988
|
-
details: `Invalid JWT token: ${err instanceof Error ? err.message : String(err)}`
|
|
2989
|
-
});
|
|
2990
|
-
}
|
|
2991
|
-
} else {
|
|
2992
|
-
await proceed();
|
|
2993
|
-
}
|
|
2994
|
-
}
|
|
2995
|
-
});
|
|
2996
|
-
this.boundPort = await this.rpcServer.listen(port);
|
|
2997
|
-
log.info(
|
|
2998
|
-
`[LIOP-SDK] Node successfully announced to Mesh. PeerID: ${this.meshNode.getPeerId()}`
|
|
2999
|
-
);
|
|
3000
|
-
}
|
|
3001
|
-
/**
|
|
3002
|
-
* Internal worker execution with Egress Filtering logic.
|
|
3003
|
-
*/
|
|
3004
|
-
async executeInWorkerPool(_args, rawPayload, toolName) {
|
|
3005
|
-
try {
|
|
3006
|
-
const dpPolicy = toolName ? this.tools.get(toolName)?.policy : void 0;
|
|
3007
|
-
const dpConfig = dpPolicy ? {
|
|
3008
|
-
epsilon: dpPolicy.dpEpsilon ?? 1,
|
|
3009
|
-
sensitivity: dpPolicy.dpSensitivity ?? 1,
|
|
3010
|
-
smallDatasetThreshold: dpPolicy.dpSmallDatasetThreshold ?? 50
|
|
3011
|
-
} : void 0;
|
|
3012
|
-
const workerResponse = await this.workerPool.run({
|
|
3013
|
-
ciphertext: new Uint8Array(0),
|
|
3014
|
-
secretKeyObj: Array.from(new Uint8Array(0)),
|
|
3015
|
-
kyberPublicKey: new Uint8Array(0),
|
|
3016
|
-
wasmBinary: Buffer.from(rawPayload),
|
|
3017
|
-
inputs: {},
|
|
3018
|
-
records: this.sandboxRecords,
|
|
3019
|
-
sessionToken: "local-dev-token",
|
|
3020
|
-
isEncrypted: false,
|
|
3021
|
-
// Use plaintext for local Logic-on-Origin injection
|
|
3022
|
-
dpConfig
|
|
3023
|
-
// Pass DP Config to apply inside worker before ZK-Receipt commitment
|
|
3024
|
-
});
|
|
3025
|
-
const dpOutput = workerResponse.output;
|
|
3026
|
-
const sanitizedOutput = sanitizeOutput(dpOutput);
|
|
3027
|
-
const textOutput = JSON.stringify({
|
|
3028
|
-
computation_result: sanitizedOutput,
|
|
3029
|
-
image_id: workerResponse.image_id,
|
|
3030
|
-
zk_receipt: workerResponse.zk_receipt,
|
|
3031
|
-
status: "Worker Pool Execution Success"
|
|
3032
|
-
});
|
|
3033
|
-
const content = [
|
|
3034
|
-
{
|
|
3035
|
-
type: "text",
|
|
3036
|
-
text: textOutput
|
|
3037
|
-
}
|
|
3038
|
-
];
|
|
3039
|
-
const toolPolicy = toolName ? this.tools.get(toolName)?.policy : void 0;
|
|
3040
|
-
const policyViolation = this.validateOutputPolicy(
|
|
3041
|
-
toolName || "unknown_tool",
|
|
3042
|
-
sanitizedOutput,
|
|
3043
|
-
// Phase 109: Validate NOISY output to ensure invariants
|
|
3044
|
-
toolPolicy
|
|
3045
|
-
);
|
|
3046
|
-
if (policyViolation) {
|
|
3047
|
-
log.info(
|
|
3048
|
-
`[LIOP-SDK] Output policy blocked for ${toolName || "unknown_tool"}: ${policyViolation}`
|
|
3049
|
-
);
|
|
3050
|
-
const isDev = process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test" || process.env.LIOP_SEC_VERBOSE === "1";
|
|
3051
|
-
const errorMessage = isDev ? policyViolation : "[LIOP] Egress Security Violation. Output blocked due to policy enforcement. Ensure your logic uses strictly aggregated, non-PII patterns.";
|
|
3052
|
-
return {
|
|
3053
|
-
content: [
|
|
3054
|
-
{
|
|
3055
|
-
type: "text",
|
|
3056
|
-
text: errorMessage
|
|
3057
|
-
}
|
|
3058
|
-
],
|
|
3059
|
-
isError: true
|
|
3060
|
-
};
|
|
3061
|
-
}
|
|
3062
|
-
const violation = await this.piiScanner.scan(sanitizedOutput);
|
|
3063
|
-
const aggregationViolation = this.violatesAggregationFirstPolicy(
|
|
3064
|
-
sanitizedOutput,
|
|
3065
|
-
// Phase 109: Validate NOISY output
|
|
3066
|
-
toolPolicy?.enforceAggregationFirst,
|
|
3067
|
-
this.sandboxRecords?.length
|
|
3068
|
-
);
|
|
3069
|
-
if (violation || aggregationViolation) {
|
|
3070
|
-
const internalReason = violation || "Aggregation-First Policy Violation: Output blocked due to dynamic flat-key policy enforcement.";
|
|
3071
|
-
log.info(
|
|
3072
|
-
`[LIOP-SDK] Secure egress blocked in local execution: ${internalReason}`
|
|
3073
|
-
);
|
|
3074
|
-
const isDev = process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test" || process.env.LIOP_SEC_VERBOSE === "1";
|
|
3075
|
-
const errorMessage = isDev ? `[LIOP] Egress Security Violation: ${internalReason}` : "[LIOP] Egress Security Violation. Output blocked due to policy enforcement. Ensure your logic uses strictly aggregated, non-PII patterns.";
|
|
3076
|
-
return {
|
|
3077
|
-
content: [
|
|
3078
|
-
{
|
|
3079
|
-
type: "text",
|
|
3080
|
-
text: errorMessage
|
|
3081
|
-
}
|
|
3082
|
-
],
|
|
3083
|
-
isError: true
|
|
3084
|
-
};
|
|
3085
|
-
}
|
|
3086
|
-
return { content };
|
|
3087
|
-
} catch (error) {
|
|
3088
|
-
const e = error;
|
|
3089
|
-
const isDev = process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test" || process.env.LIOP_SEC_VERBOSE === "1";
|
|
3090
|
-
const detail = e.message || String(error);
|
|
3091
|
-
log.error(`[LIOP-SDK] WorkerPool Execution Fault: ${detail}`);
|
|
3092
|
-
const isOom = detail.includes("worker_thread_exited") || detail.includes("ERR_WORKER_OUT_OF_MEMORY") || detail.includes("terminated") || detail.includes("heap limit");
|
|
3093
|
-
const errorMessage = isOom ? "[LIOP] Execution terminated: memory limit exceeded (64MB heap). Reduce data processing volume." : isDev ? `WorkerPoolError: ${detail}` : "[LIOP] Execution Failed. The injected logic violated runtime constraints or encountered a fatal error.";
|
|
3094
|
-
return {
|
|
3095
|
-
content: [
|
|
3096
|
-
{
|
|
3097
|
-
type: "text",
|
|
3098
|
-
text: errorMessage
|
|
3099
|
-
}
|
|
3100
|
-
],
|
|
3101
|
-
isError: true
|
|
3102
|
-
};
|
|
3103
|
-
}
|
|
3104
|
-
}
|
|
3105
|
-
/**
|
|
3106
|
-
* Safely destroys the worker pool, gRPC server, and Mesh node.
|
|
3107
|
-
* Recommended to be called during graceful shutdowns or test teardowns.
|
|
3108
|
-
*/
|
|
3109
|
-
async close() {
|
|
3110
|
-
if (this.workerPool) {
|
|
3111
|
-
await this.workerPool.close({ force: true });
|
|
3112
|
-
}
|
|
3113
|
-
if (this.rpcServer) {
|
|
3114
|
-
await this.rpcServer.stop();
|
|
3115
|
-
}
|
|
3116
|
-
if (this.meshNode) {
|
|
3117
|
-
await this.meshNode.stop();
|
|
3118
|
-
}
|
|
3119
|
-
}
|
|
3120
|
-
loadRevocationList() {
|
|
3121
|
-
const rPath = this.config?.auth?.revocationPath;
|
|
3122
|
-
if (!rPath) return;
|
|
3123
|
-
try {
|
|
3124
|
-
if (fs.existsSync(rPath)) {
|
|
3125
|
-
const stats = fs.statSync(rPath);
|
|
3126
|
-
if (stats.mtimeMs <= this.lastRevocationLoadTime) {
|
|
3127
|
-
return;
|
|
3128
|
-
}
|
|
3129
|
-
const content = fs.readFileSync(rPath, "utf-8");
|
|
3130
|
-
const list = JSON.parse(content);
|
|
3131
|
-
if (Array.isArray(list)) {
|
|
3132
|
-
this.revokedTokenHashes.clear();
|
|
3133
|
-
for (const item of list) {
|
|
3134
|
-
if (typeof item === "string") {
|
|
3135
|
-
this.revokedTokenHashes.add(item.trim().toLowerCase());
|
|
3136
|
-
}
|
|
3137
|
-
}
|
|
3138
|
-
this.lastRevocationLoadTime = stats.mtimeMs;
|
|
3139
|
-
log.info(
|
|
3140
|
-
`[LiopServer] Loaded ${this.revokedTokenHashes.size} revoked token hashes from ${rPath}`
|
|
3141
|
-
);
|
|
3142
|
-
}
|
|
3143
|
-
} else {
|
|
3144
|
-
const dir = path.dirname(rPath);
|
|
3145
|
-
if (!fs.existsSync(dir)) {
|
|
3146
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
3147
|
-
}
|
|
3148
|
-
fs.writeFileSync(rPath, JSON.stringify([], null, 2), "utf-8");
|
|
3149
|
-
this.lastRevocationLoadTime = Date.now();
|
|
3150
|
-
log.info(
|
|
3151
|
-
`[LiopServer] Created empty local revocation list at ${rPath}`
|
|
3152
|
-
);
|
|
3153
|
-
}
|
|
3154
|
-
} catch (err) {
|
|
3155
|
-
log.error(
|
|
3156
|
-
`[LiopServer] Failed to load revocation list: ${err instanceof Error ? err.message : String(err)}`
|
|
3157
|
-
);
|
|
3158
|
-
}
|
|
3159
|
-
}
|
|
3160
|
-
revokeToken(token) {
|
|
3161
|
-
if (!token) return;
|
|
3162
|
-
const hash = crypto2.createHash("sha256").update(token).digest("hex").toLowerCase();
|
|
3163
|
-
this.revokeTokenHash(hash);
|
|
3164
|
-
}
|
|
3165
|
-
revokeTokenHash(hash) {
|
|
3166
|
-
if (!hash) return;
|
|
3167
|
-
const normalizedHash = hash.toLowerCase();
|
|
3168
|
-
this.loadRevocationList();
|
|
3169
|
-
this.revokedTokenHashes.add(normalizedHash);
|
|
3170
|
-
const rPath = this.config?.auth?.revocationPath;
|
|
3171
|
-
if (rPath) {
|
|
3172
|
-
try {
|
|
3173
|
-
const dir = path.dirname(rPath);
|
|
3174
|
-
if (!fs.existsSync(dir)) {
|
|
3175
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
3176
|
-
}
|
|
3177
|
-
const hashes = Array.from(this.revokedTokenHashes);
|
|
3178
|
-
fs.writeFileSync(rPath, JSON.stringify(hashes, null, 2), "utf-8");
|
|
3179
|
-
const stats = fs.statSync(rPath);
|
|
3180
|
-
this.lastRevocationLoadTime = stats.mtimeMs;
|
|
3181
|
-
log.info(
|
|
3182
|
-
`[LiopServer] Persisted revocation for hash ${normalizedHash} to ${rPath}`
|
|
3183
|
-
);
|
|
3184
|
-
} catch (err) {
|
|
3185
|
-
log.error(
|
|
3186
|
-
`[LiopServer] Failed to persist revocation: ${err instanceof Error ? err.message : String(err)}`
|
|
3187
|
-
);
|
|
3188
|
-
}
|
|
3189
|
-
}
|
|
3190
|
-
}
|
|
3191
|
-
getPersistentBudget(storePath) {
|
|
3192
|
-
try {
|
|
3193
|
-
if (!fs.existsSync(storePath)) {
|
|
3194
|
-
return {};
|
|
3195
|
-
}
|
|
3196
|
-
const content = fs.readFileSync(storePath, "utf-8");
|
|
3197
|
-
return JSON.parse(content || "{}");
|
|
3198
|
-
} catch {
|
|
3199
|
-
return {};
|
|
3200
|
-
}
|
|
3201
|
-
}
|
|
3202
|
-
savePersistentBudget(storePath, budget) {
|
|
3203
|
-
const tempPath = `${storePath}.tmp`;
|
|
3204
|
-
const dir = path.dirname(storePath);
|
|
3205
|
-
if (!fs.existsSync(dir)) {
|
|
3206
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
3207
|
-
}
|
|
3208
|
-
fs.writeFileSync(tempPath, JSON.stringify(budget, null, 2), "utf-8");
|
|
3209
|
-
fs.renameSync(tempPath, storePath);
|
|
3210
|
-
}
|
|
3211
|
-
executeWithBudgetLock(storePath, action) {
|
|
3212
|
-
const lockPath = `${storePath}.lock`;
|
|
3213
|
-
const maxRetries = 100;
|
|
3214
|
-
let attempts = 0;
|
|
3215
|
-
const dir = path.dirname(storePath);
|
|
3216
|
-
if (!fs.existsSync(dir)) {
|
|
3217
|
-
try {
|
|
3218
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
3219
|
-
} catch {
|
|
3220
|
-
}
|
|
3221
|
-
}
|
|
3222
|
-
while (attempts < maxRetries) {
|
|
3223
|
-
try {
|
|
3224
|
-
fs.writeFileSync(lockPath, process.pid.toString(), { flag: "wx" });
|
|
3225
|
-
break;
|
|
3226
|
-
} catch (e) {
|
|
3227
|
-
if (e && typeof e === "object" && "code" in e && e.code === "EEXIST") {
|
|
3228
|
-
attempts++;
|
|
3229
|
-
} else {
|
|
3230
|
-
throw e;
|
|
3231
|
-
}
|
|
3232
|
-
}
|
|
3233
|
-
}
|
|
3234
|
-
if (attempts >= maxRetries) {
|
|
3235
|
-
throw new Error(
|
|
3236
|
-
"Timeout acquiring lock for persistent query budget database"
|
|
3237
|
-
);
|
|
3238
|
-
}
|
|
3239
|
-
try {
|
|
3240
|
-
const currentBudget = this.getPersistentBudget(storePath);
|
|
3241
|
-
const { result, updatedBudget } = action(currentBudget);
|
|
3242
|
-
if (updatedBudget) {
|
|
3243
|
-
this.savePersistentBudget(storePath, updatedBudget);
|
|
3244
|
-
}
|
|
3245
|
-
return result;
|
|
3246
|
-
} finally {
|
|
3247
|
-
try {
|
|
3248
|
-
if (fs.existsSync(lockPath)) {
|
|
3249
|
-
fs.unlinkSync(lockPath);
|
|
3250
|
-
}
|
|
3251
|
-
} catch {
|
|
3252
|
-
}
|
|
3253
|
-
}
|
|
3254
|
-
}
|
|
3255
|
-
applyInMemoryBudget(clientId, _toolName, extractedFields, policy) {
|
|
3256
|
-
let clientBudget = this.fieldQueryBudget.get(clientId);
|
|
3257
|
-
if (!clientBudget) {
|
|
3258
|
-
clientBudget = /* @__PURE__ */ new Map();
|
|
3259
|
-
this.fieldQueryBudget.set(clientId, clientBudget);
|
|
3260
|
-
}
|
|
3261
|
-
let toolBudget = clientBudget.get(_toolName);
|
|
3262
|
-
if (!toolBudget) {
|
|
3263
|
-
toolBudget = /* @__PURE__ */ new Map();
|
|
3264
|
-
clientBudget.set(_toolName, toolBudget);
|
|
3265
|
-
}
|
|
3266
|
-
for (const field of extractedFields) {
|
|
3267
|
-
const sensitivity = this.taintAnalyzer.classifyField(
|
|
3268
|
-
field,
|
|
3269
|
-
policy?.sensitiveKeys
|
|
3270
|
-
);
|
|
3271
|
-
let queryLimit = 25;
|
|
3272
|
-
let sensLabel = "public";
|
|
3273
|
-
if (policy?.queryBudgetPerField !== void 0) {
|
|
3274
|
-
queryLimit = policy.queryBudgetPerField;
|
|
3275
|
-
sensLabel = "override";
|
|
3276
|
-
} else if (sensitivity === "forbidden") {
|
|
3277
|
-
queryLimit = 3;
|
|
3278
|
-
sensLabel = "forbidden";
|
|
3279
|
-
} else if (sensitivity === "sensitive") {
|
|
3280
|
-
queryLimit = 8;
|
|
3281
|
-
sensLabel = "sensitive";
|
|
3282
|
-
}
|
|
3283
|
-
const count = toolBudget.get(field) ?? 0;
|
|
3284
|
-
if (count >= queryLimit) {
|
|
3285
|
-
return `Preflight policy rejected: Query budget exceeded for field '${field}' (max ${queryLimit} per session for ${sensLabel} fields). Rotate PQC session to reset budget.`;
|
|
3286
|
-
}
|
|
3287
|
-
}
|
|
3288
|
-
for (const field of extractedFields) {
|
|
3289
|
-
const count = toolBudget.get(field) ?? 0;
|
|
3290
|
-
toolBudget.set(field, count + 1);
|
|
3291
|
-
}
|
|
3292
|
-
return null;
|
|
3293
|
-
}
|
|
3294
|
-
};
|
|
3295
|
-
|
|
3296
|
-
export { AUTH_DEFAULTS, JwtValidator, LiopRpcServer, LiopServer, NerScanner, PII_PATTERNS, PII_PRESETS, PiiScanner, createOAuthServer, sanitizeOutput };
|
|
3297
|
-
//# sourceMappingURL=chunk-4KIGYPIQ.js.map
|
|
3298
|
-
//# sourceMappingURL=chunk-4KIGYPIQ.js.map
|