@anteros/core 0.0.44 → 0.0.47
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/LICENSE +21 -0
- package/README.md +1 -1
- package/database/field.crypto.ts +38 -13
- package/database/mongodbadapter.ts +85 -25
- package/database/vars.ts +22 -2
- package/index.ts +22 -0
- package/lib/agent.ts +707 -79
- package/lib/agentMemory.ts +199 -0
- package/lib/agents.ts +133 -52
- package/lib/attachment.ts +24 -3
- package/lib/define.ts +5 -1
- package/lib/eval.ts +123 -0
- package/lib/files.ts +21 -15
- package/lib/jsonSchema.ts +8 -0
- package/lib/mcp.ts +52 -30
- package/lib/messages.ts +202 -19
- package/lib/partialJson.ts +79 -0
- package/lib/providers.ts +26 -2
- package/lib/redact.ts +11 -0
- package/lib/tracing.ts +20 -0
- package/lib/zip.ts +23 -3
- package/package.json +4 -5
- package/server/agents.ts +66 -1
- package/server/api.ts +302 -20
- package/server/boot.ts +13 -4
- package/server/hono.ts +146 -6
- package/tests/access-log-body.test.ts +253 -0
- package/tests/agent-checkpoint.test.ts +195 -0
- package/tests/agent-hardening.test.ts +238 -0
- package/tests/agent-memory.test.ts +175 -1
- package/tests/agent-stream-advanced.test.ts +246 -0
- package/tests/agent-tracing-eval.test.ts +219 -0
- package/tests/agent-working-memory.test.ts +7 -4
- package/tests/agent.test.ts +54 -3
- package/tests/agents-api.test.ts +21 -0
- package/tests/api-external-guards.test.ts +304 -0
- package/tests/attachment.test.ts +63 -0
- package/tests/audit-redact.test.ts +26 -1
- package/tests/field-encryption.test.ts +47 -3
- package/tests/filters-hardening.test.ts +81 -0
- package/tests/fixtures/ai-tenant/ai/agents/support/billing.agent.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support/billing.tool.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support.agent.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support.tool.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/mcp/resources/readme.resource.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/mcp/tools/echo.tool.ts +9 -0
- package/tests/fixtures/fake-provider.ts +33 -0
- package/tests/fixtures/tool-tenant/ai/agents/report.agent.ts +12 -0
- package/tests/fixtures/tool-tenant/ai/agents/report.tool.ts +12 -0
- package/tests/fixtures/vars-tenant/vars/open.var.ts +18 -0
- package/tests/hono.test.ts +81 -1
- package/tests/jwt-tenant.test.ts +165 -0
- package/tests/logger.test.ts +62 -10
- package/tests/messages.test.ts +88 -32
- package/tests/tool-runtime.test.ts +291 -0
- package/tests/tool-types.ts +33 -0
- package/tests/upload.test.ts +175 -3
- package/tests/vars-api.test.ts +73 -1
- package/tests/vars.test.ts +1 -1
- package/types/agent.d.ts +386 -4
- package/types/config.d.ts +56 -2
- package/types/file.d.ts +1 -1
- package/types/mcp.d.ts +2 -2
- package/utils/func.ts +20 -0
- package/utils/logger.ts +125 -33
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dnax Inc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
package/database/field.crypto.ts
CHANGED
|
@@ -40,6 +40,23 @@ export function encryptedPaths(col: Collection | null): PathField[] {
|
|
|
40
40
|
}));
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* AAD context (#44): a ciphertext is bound to its tenant, collection and field.
|
|
45
|
+
* Legacy ciphertexts (no AAD) still decrypt through the fallback in `decryptStored`.
|
|
46
|
+
*/
|
|
47
|
+
function fieldAad(col: Collection | null, name: string): string {
|
|
48
|
+
return `${col?._tenant_ ?? "?"}:${col?.slug ?? "?"}:${name}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Try the contextual AAD first, then the legacy no-AAD ciphertexts. */
|
|
52
|
+
async function decryptStored(cipher: any, value: string, aad: string): Promise<any> {
|
|
53
|
+
try {
|
|
54
|
+
return await cipher.decrypt(value, true, aad);
|
|
55
|
+
} catch {
|
|
56
|
+
return cipher.decrypt(value, true);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
function requireConfig(col: Collection | null, targets: string[]) {
|
|
44
61
|
if (!cfg.server.encryption?.mode) {
|
|
45
62
|
throw new AppError(
|
|
@@ -67,16 +84,16 @@ function setPath(obj: Record<string, any>, path: string, value: any): void {
|
|
|
67
84
|
}
|
|
68
85
|
|
|
69
86
|
/** Encrypt the values at `paths` inside a json value — object or array of json objects */
|
|
70
|
-
async function encryptAt(cipher: any, value: any, paths: string[]): Promise<any> {
|
|
87
|
+
async function encryptAt(cipher: any, value: any, paths: string[], aad: string): Promise<any> {
|
|
71
88
|
if (Array.isArray(value)) {
|
|
72
|
-
return Promise.all(value.map((item) => encryptAt(cipher, item, paths)));
|
|
89
|
+
return Promise.all(value.map((item) => encryptAt(cipher, item, paths, aad)));
|
|
73
90
|
}
|
|
74
91
|
if (!value || typeof value !== "object") return value;
|
|
75
92
|
const out: Record<string, any> = { ...value };
|
|
76
93
|
for (const p of paths) {
|
|
77
94
|
const v = getPath(out, p);
|
|
78
95
|
if (v !== undefined && v !== null) {
|
|
79
|
-
setPath(out, p, await cipher.encrypt(JSON.stringify(v))); // JSON-wrap → decrypt(..., true) round-trips any value
|
|
96
|
+
setPath(out, p, await cipher.encrypt(JSON.stringify(v), aad)); // JSON-wrap → decrypt(..., true) round-trips any value
|
|
80
97
|
}
|
|
81
98
|
}
|
|
82
99
|
return out;
|
|
@@ -99,14 +116,14 @@ export async function encryptFields(col: Collection | null, data: Record<string,
|
|
|
99
116
|
for (const name of names) {
|
|
100
117
|
const v = out[name];
|
|
101
118
|
if (v !== undefined && v !== null) {
|
|
102
|
-
out[name] = await cipher.encrypt(JSON.stringify(v));
|
|
119
|
+
out[name] = await cipher.encrypt(JSON.stringify(v), fieldAad(col, name));
|
|
103
120
|
}
|
|
104
121
|
}
|
|
105
122
|
// Path fields (encryptionOptions.path) — encrypt only the sub-path(s)
|
|
106
123
|
for (const { name, paths } of pathFields) {
|
|
107
124
|
const v = out[name];
|
|
108
125
|
if (v !== undefined && v !== null) {
|
|
109
|
-
out[name] = await encryptAt(cipher, v, paths);
|
|
126
|
+
out[name] = await encryptAt(cipher, v, paths, fieldAad(col, name));
|
|
110
127
|
}
|
|
111
128
|
}
|
|
112
129
|
return out;
|
|
@@ -125,6 +142,13 @@ export async function encryptUpdate(col: Collection | null, update: Record<strin
|
|
|
125
142
|
// Resolve a $set key → whole-field | sub-path of a path-field | null (not encrypted)
|
|
126
143
|
const resolveKey = (k: string): { kind: "whole" | "path"; field: string; sub: string | null; paths?: string[] } | null => {
|
|
127
144
|
if (nameSet.has(k)) return { kind: "whole", field: k, sub: null };
|
|
145
|
+
// Writing a sub-path of a whole encrypted field would store plaintext inside it (#44).
|
|
146
|
+
if (names.some((name) => k.startsWith(name + "."))) {
|
|
147
|
+
throw new AppError(
|
|
148
|
+
`Field '${k}' is a sub-path of an encrypted field — write the whole field instead`,
|
|
149
|
+
{ code: "ENCRYPTED_FIELD_OPERATOR", status: 400 },
|
|
150
|
+
);
|
|
151
|
+
}
|
|
128
152
|
const pf = pathFields.find((p) => k === p.name || k.startsWith(p.name + "."));
|
|
129
153
|
if (pf) {
|
|
130
154
|
const sub = k === pf.name ? null : k.slice(pf.name.length + 1);
|
|
@@ -149,14 +173,15 @@ export async function encryptUpdate(col: Collection | null, update: Record<strin
|
|
|
149
173
|
for (const { k, hit } of hits) {
|
|
150
174
|
const v = newValues[k];
|
|
151
175
|
if (v === undefined || v === null) continue;
|
|
176
|
+
const aad = fieldAad(col, hit!.field);
|
|
152
177
|
if (hit!.kind === "whole") {
|
|
153
|
-
newValues[k] = await cipher.encrypt(JSON.stringify(v));
|
|
178
|
+
newValues[k] = await cipher.encrypt(JSON.stringify(v), aad);
|
|
154
179
|
} else if (hit!.sub === null) {
|
|
155
180
|
// whole json replacement → encrypt the configured paths inside
|
|
156
|
-
newValues[k] = await encryptAt(cipher, v, hit!.paths
|
|
181
|
+
newValues[k] = await encryptAt(cipher, v, hit!.paths!, aad);
|
|
157
182
|
} else {
|
|
158
183
|
// dot-notation leaf ('address.zip') → encrypt the value directly
|
|
159
|
-
newValues[k] = await cipher.encrypt(JSON.stringify(v));
|
|
184
|
+
newValues[k] = await cipher.encrypt(JSON.stringify(v), aad);
|
|
160
185
|
}
|
|
161
186
|
}
|
|
162
187
|
out[op] = newValues;
|
|
@@ -186,7 +211,7 @@ export async function decryptFields(col: Collection | null, doc: any): Promise<a
|
|
|
186
211
|
const v = out[name];
|
|
187
212
|
if (typeof v === "string") {
|
|
188
213
|
try {
|
|
189
|
-
out[name] = await cipher
|
|
214
|
+
out[name] = await decryptStored(cipher, v, fieldAad(col, name));
|
|
190
215
|
} catch {
|
|
191
216
|
// not a ciphertext (legacy plaintext) → keep as-is
|
|
192
217
|
}
|
|
@@ -194,14 +219,14 @@ export async function decryptFields(col: Collection | null, doc: any): Promise<a
|
|
|
194
219
|
}
|
|
195
220
|
for (const { name, paths } of pathFields) {
|
|
196
221
|
const v = out[name];
|
|
197
|
-
if (v && typeof v === "object") out[name] = await decryptAt(cipher, v, paths);
|
|
222
|
+
if (v && typeof v === "object") out[name] = await decryptAt(cipher, v, paths, fieldAad(col, name));
|
|
198
223
|
}
|
|
199
224
|
return out;
|
|
200
225
|
}
|
|
201
226
|
|
|
202
|
-
async function decryptAt(cipher: any, value: any, paths: string[]): Promise<any> {
|
|
227
|
+
async function decryptAt(cipher: any, value: any, paths: string[], aad: string): Promise<any> {
|
|
203
228
|
if (Array.isArray(value)) {
|
|
204
|
-
return Promise.all(value.map((item) => decryptAt(cipher, item, paths)));
|
|
229
|
+
return Promise.all(value.map((item) => decryptAt(cipher, item, paths, aad)));
|
|
205
230
|
}
|
|
206
231
|
if (!value || typeof value !== "object") return value;
|
|
207
232
|
const out: Record<string, any> = { ...value };
|
|
@@ -209,7 +234,7 @@ async function decryptAt(cipher: any, value: any, paths: string[]): Promise<any>
|
|
|
209
234
|
const v = getPath(out, p);
|
|
210
235
|
if (typeof v === "string") {
|
|
211
236
|
try {
|
|
212
|
-
setPath(out, p, await cipher
|
|
237
|
+
setPath(out, p, await decryptStored(cipher, v, aad));
|
|
213
238
|
} catch {
|
|
214
239
|
// not a ciphertext → keep as-is
|
|
215
240
|
}
|
|
@@ -6,6 +6,29 @@ import type { MongoClientOptions, Db, ClientSession, UpdateOptions, UpdateFilter
|
|
|
6
6
|
* Is a process still running **on this host**? `EPERM` means it exists but belongs
|
|
7
7
|
* to another user — which still counts as alive.
|
|
8
8
|
*/
|
|
9
|
+
/** Bulk operations embed their filter under the operation name — sanitize all of them (#45). */
|
|
10
|
+
function assertBulkFilters(operations: any[]): void {
|
|
11
|
+
for (const operation of operations) {
|
|
12
|
+
func.assertSafeFilter(operation?.filter);
|
|
13
|
+
for (const value of Object.values(operation ?? {})) {
|
|
14
|
+
if (value && typeof value === "object") func.assertSafeFilter((value as any).filter);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `$rename` may not target a read-only field (#43) — the external API already refuses it. */
|
|
20
|
+
function assertReadOnlyRename(col: any, update: any): void {
|
|
21
|
+
const rename = update?.$rename;
|
|
22
|
+
if (!rename || typeof rename !== "object") return;
|
|
23
|
+
const fields = (col?.api?.readOnlyFields ?? []) as (string | RegExp)[];
|
|
24
|
+
for (const target of Object.values(rename)) {
|
|
25
|
+
const name = String(target);
|
|
26
|
+
if (fields.some((field) => (typeof field === "string" ? field === name : field.test(name)))) {
|
|
27
|
+
throw new AppError(`Field '${name}' is read-only`, { status: 400, code: "READONLY_FIELD" });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
9
32
|
function isProcessAlive(pid: number): boolean {
|
|
10
33
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
11
34
|
try {
|
|
@@ -15,6 +38,25 @@ function isProcessAlive(pid: number): boolean {
|
|
|
15
38
|
return err?.code === 'EPERM';
|
|
16
39
|
}
|
|
17
40
|
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a collection definition or fail with the framework's own error.
|
|
44
|
+
*
|
|
45
|
+
* The `@CheckIfCollectionExists()` guard runs before the method, but the definition set
|
|
46
|
+
* can be replaced between the guard and the second lookup (a reload, or a deferred
|
|
47
|
+
* factory such as the query cache). Never cast `getCollection()` to `Collection` — a
|
|
48
|
+
* vanished definition would turn into `null is not an object (evaluating 'col.hooks')`.
|
|
49
|
+
*/
|
|
50
|
+
function requireCollection(collectionName: string, tenantId: string): Collection {
|
|
51
|
+
const col = getCollection(collectionName, tenantId)
|
|
52
|
+
if (!col) {
|
|
53
|
+
throw new AppError(`collection '${collectionName}' not found`, {
|
|
54
|
+
code: 'COLLECTION_NOT_FOUND',
|
|
55
|
+
status: 500,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
return col
|
|
59
|
+
}
|
|
18
60
|
import { MongoClient, ObjectId, AggregationCursor } from "mongodb";
|
|
19
61
|
import type { FindOptions, findOneOptions } from "../types/mongo";
|
|
20
62
|
import type { FindCallOptions } from "../types/mongo";
|
|
@@ -27,7 +69,7 @@ import { getTenant } from "./tenant";
|
|
|
27
69
|
import { getCollection, getCollectionKeys } from "./collection"
|
|
28
70
|
import { AUDIT_COLLECTION } from "./audit"
|
|
29
71
|
import { writeAuditFile } from "../lib/audit"
|
|
30
|
-
import { redactPayload } from "../lib/redact"
|
|
72
|
+
import { redactPayload, redactText } from "../lib/redact"
|
|
31
73
|
import { summarizeAuditResult } from "../lib/auditResult"
|
|
32
74
|
import { encryptFields, encryptUpdate } from "./field.crypto"
|
|
33
75
|
import type { RestActions, BulkUpdateOperation, RestOptions, CursorOptions, TenantCache } from "../types/rest";
|
|
@@ -361,9 +403,10 @@ class MongoRest {
|
|
|
361
403
|
|
|
362
404
|
@CheckIfCollectionExists()
|
|
363
405
|
async watch(collection: string, pipeline: any[], options: ChangeStreamOptions): Promise<ChangeStream> {
|
|
406
|
+
func.boundMatchOperators(pipeline)
|
|
364
407
|
// Audited with the parameters only — the change stream itself is a live object
|
|
365
408
|
return this.#executeWithAudit('watch', collection, { pipeline, options }, async () => {
|
|
366
|
-
const col =
|
|
409
|
+
const col = requireCollection(collection, this.#tenant.id)
|
|
367
410
|
const isSafe = func.isSafeAggregatePipeline(pipeline);
|
|
368
411
|
if (!isSafe.isSafe) {
|
|
369
412
|
throw isSafe.error;
|
|
@@ -384,7 +427,7 @@ class MongoRest {
|
|
|
384
427
|
const action = 'aggregate' as ActionsApiList
|
|
385
428
|
return this.#executeReadWithAudit(action, collection, { pipeline }, async () => {
|
|
386
429
|
try {
|
|
387
|
-
const col =
|
|
430
|
+
const col = requireCollection(collection, this.#tenant.id)
|
|
388
431
|
// Flaw 12: restrict $lookup targets to this tenant's declared collections
|
|
389
432
|
const allowedCollections = [
|
|
390
433
|
...(cfg.collections ?? [])
|
|
@@ -399,6 +442,7 @@ class MongoRest {
|
|
|
399
442
|
if (!isSafe.isSafe) {
|
|
400
443
|
throw isSafe.error;
|
|
401
444
|
}
|
|
445
|
+
func.boundMatchOperators(pipeline);
|
|
402
446
|
pipeline = func.toBson(pipeline, { col })
|
|
403
447
|
const meta: any = { action, collection, pipeline }
|
|
404
448
|
if (col.hooks?.beforeOperation) {
|
|
@@ -464,7 +508,7 @@ class MongoRest {
|
|
|
464
508
|
const countPromise = withCount
|
|
465
509
|
? this.countDocuments(collection, params.$match ?? {})
|
|
466
510
|
: null;
|
|
467
|
-
const col =
|
|
511
|
+
const col = requireCollection(collection, this.#tenant.id)
|
|
468
512
|
let pipeline = func.buildPipeline({ ...params, $limit: params.$limit ?? 0 }, { col: col })
|
|
469
513
|
pipeline = await func.buildInput(pipeline, { rest: this })
|
|
470
514
|
pipeline = func.toBson(pipeline, { col })
|
|
@@ -540,7 +584,7 @@ class MongoRest {
|
|
|
540
584
|
// `break`/`return()` — which triggers the `finally` below). Parameters only.
|
|
541
585
|
const start = Date.now();
|
|
542
586
|
let failure: any = null;
|
|
543
|
-
const col =
|
|
587
|
+
const col = requireCollection(collection, this.#tenant.id)
|
|
544
588
|
const allowedCollections = [
|
|
545
589
|
...(cfg.collections ?? []).filter((c) => c._tenant_ === this.tenant_id).map((c) => c.slug),
|
|
546
590
|
...(cfg.fileCollections ?? []).filter((fc) => fc._tenant_ === this.tenant_id).map((fc) => fc.slug),
|
|
@@ -599,11 +643,13 @@ class MongoRest {
|
|
|
599
643
|
@CheckIfCollectionExists()
|
|
600
644
|
async find(collection: string, params: FindOptions = {}, options: FindCallOptions = {}): Promise<Document[]> {
|
|
601
645
|
const action = 'find' as ActionsApiList
|
|
646
|
+
// Resolve once, before any await: a cache-miss factory may run later, after the
|
|
647
|
+
// definition set was replaced — the call keeps the collection it started with.
|
|
648
|
+
const col = requireCollection(collection, this.#tenant.id)
|
|
602
649
|
return this.#executeReadWithAudit(action, collection, { params, options }, async () => {
|
|
603
650
|
try {
|
|
604
651
|
// Real query — hooks + pipeline + toArray
|
|
605
652
|
const run = async (): Promise<Document[]> => {
|
|
606
|
-
const col = getCollection(collection, this.#tenant.id) as Collection
|
|
607
653
|
let pipeline = func.buildPipeline(params, { col: col })
|
|
608
654
|
pipeline = await func.buildInput(pipeline, { rest: this })
|
|
609
655
|
pipeline = func.toBson(pipeline, { col })
|
|
@@ -644,7 +690,7 @@ class MongoRest {
|
|
|
644
690
|
const action = 'findOne' as ActionsApiList
|
|
645
691
|
return this.#executeReadWithAudit(action, collection, { id: _id, params }, async () => {
|
|
646
692
|
try {
|
|
647
|
-
const col =
|
|
693
|
+
const col = requireCollection(collection, this.#tenant.id)
|
|
648
694
|
let pipeline = func.buildPipeline({
|
|
649
695
|
...params,
|
|
650
696
|
$match: { _id },
|
|
@@ -682,7 +728,7 @@ class MongoRest {
|
|
|
682
728
|
async insertOne<T>(collection: string, data: T): Promise<T & { _id: string }> {
|
|
683
729
|
const action = 'insertOne' as ActionsApiList
|
|
684
730
|
return this.#executeWithAudit(action, collection, { data }, async () => {
|
|
685
|
-
const col =
|
|
731
|
+
const col = requireCollection(collection, this.#tenant.id)
|
|
686
732
|
|
|
687
733
|
data = await func.buildInput(data as T, {
|
|
688
734
|
action: action, col: col, rest: this
|
|
@@ -719,7 +765,7 @@ class MongoRest {
|
|
|
719
765
|
async insertMany<T>(collection: string, data: T[]): Promise<(T & { _id: string })[]> {
|
|
720
766
|
const action = 'insertMany' as ActionsApiList
|
|
721
767
|
return this.#executeWithAudit(action, collection, { count: data.length }, async () => {
|
|
722
|
-
const col =
|
|
768
|
+
const col = requireCollection(collection, this.#tenant.id)
|
|
723
769
|
let dataInput: T[] = []
|
|
724
770
|
|
|
725
771
|
for (let d of data) {
|
|
@@ -765,13 +811,14 @@ class MongoRest {
|
|
|
765
811
|
async updateOne(collection: string, _id: string, update: UpdateFilter<any>) {
|
|
766
812
|
const action = 'updateOne' as ActionsApiList
|
|
767
813
|
return this.#executeWithAudit(action, collection, { id: _id, update }, async () => {
|
|
768
|
-
const col =
|
|
814
|
+
const col = requireCollection(collection, this.#tenant.id)
|
|
769
815
|
|
|
770
816
|
if (update.$set) {
|
|
771
817
|
update.$set = await func.buildInput(update.$set, { action: action, col: col })
|
|
772
818
|
}
|
|
773
819
|
|
|
774
820
|
update = await func.buildInput(update)
|
|
821
|
+
assertReadOnlyRename(col, update)
|
|
775
822
|
this._validate({ collection, action: action, update: update })
|
|
776
823
|
// Field-level encryption: encrypt $set/$setOnInsert values for encrypted fields
|
|
777
824
|
update = await encryptUpdate(col, update)
|
|
@@ -803,9 +850,10 @@ class MongoRest {
|
|
|
803
850
|
@CheckIfCollectionExists()
|
|
804
851
|
@CheckFilter()
|
|
805
852
|
async findOneAndUpdate(collection: string, filter: Document, update: UpdateFilter<any>, options?: updateApiOptions): Promise<Document | null> {
|
|
853
|
+
func.assertSafeFilter(filter)
|
|
806
854
|
const action = 'findOneAndUpdate' as ActionsApiList
|
|
807
855
|
return this.#executeWithAudit(action, collection, { filter, update, options }, async () => {
|
|
808
|
-
const col =
|
|
856
|
+
const col = requireCollection(collection, this.#tenant.id)
|
|
809
857
|
|
|
810
858
|
|
|
811
859
|
|
|
@@ -875,12 +923,13 @@ class MongoRest {
|
|
|
875
923
|
async updateMany(collection: string, _ids: string[], update: UpdateFilter<any>): Promise<UpdateResult> {
|
|
876
924
|
const action = 'updateMany' as ActionsApiList
|
|
877
925
|
return this.#executeWithAudit(action, collection, { ids: _ids, update }, async () => {
|
|
878
|
-
const col =
|
|
926
|
+
const col = requireCollection(collection, this.#tenant.id)
|
|
879
927
|
|
|
880
928
|
if (update.$set) {
|
|
881
929
|
update.$set = await func.buildInput(update.$set, { action: action, col: col })
|
|
882
930
|
}
|
|
883
931
|
update = await func.buildInput(update)
|
|
932
|
+
assertReadOnlyRename(col, update)
|
|
884
933
|
this._validate({ collection, action: action, update: update })
|
|
885
934
|
// Field-level encryption: encrypt $set/$setOnInsert values for encrypted fields
|
|
886
935
|
update = await encryptUpdate(col, update)
|
|
@@ -912,7 +961,7 @@ class MongoRest {
|
|
|
912
961
|
async deleteOne(collection: string, _id: string) {
|
|
913
962
|
const action = 'deleteOne' as ActionsApiList
|
|
914
963
|
return this.#executeWithAudit(action, collection, { id: _id }, async () => {
|
|
915
|
-
const col =
|
|
964
|
+
const col = requireCollection(collection, this.#tenant.id)
|
|
916
965
|
|
|
917
966
|
const meta: any = { action, collection, id: _id }
|
|
918
967
|
if (col.hooks?.beforeOperation) {
|
|
@@ -941,7 +990,7 @@ class MongoRest {
|
|
|
941
990
|
async deleteMany(collection: string, _ids: string[]): Promise<DeleteResult> {
|
|
942
991
|
const action = 'deleteMany' as ActionsApiList
|
|
943
992
|
return this.#executeWithAudit(action, collection, { ids: _ids }, async () => {
|
|
944
|
-
const col =
|
|
993
|
+
const col = requireCollection(collection, this.#tenant.id)
|
|
945
994
|
|
|
946
995
|
const meta: any = { action, collection, ids: _ids }
|
|
947
996
|
if (col.hooks?.beforeOperation) {
|
|
@@ -968,10 +1017,11 @@ class MongoRest {
|
|
|
968
1017
|
@CheckIfCollectionExists()
|
|
969
1018
|
@CheckBulkWriteOperations()
|
|
970
1019
|
async bulkWrite(collection: string, operations: AnyBulkWriteOperation[], options?: BulkWriteOptions): Promise<BulkWriteResult> {
|
|
1020
|
+
assertBulkFilters(operations);
|
|
971
1021
|
const action = 'bulkWrite' as ActionsApiList
|
|
972
1022
|
return this.#executeWithAudit(action, collection, { count: operations.length }, async () => {
|
|
973
1023
|
for (let operation of operations) {
|
|
974
|
-
const col =
|
|
1024
|
+
const col = requireCollection(collection, this.#tenant.id)
|
|
975
1025
|
|
|
976
1026
|
if ('insertOne' in operation) {
|
|
977
1027
|
operation.insertOne.document = func.toBson(
|
|
@@ -1034,9 +1084,10 @@ class MongoRest {
|
|
|
1034
1084
|
@LogSlowQuery()
|
|
1035
1085
|
@CheckIfCollectionExists()
|
|
1036
1086
|
async bulkUpdate(collection: string, operations: BulkUpdateOperation[], options?: BulkWriteOptions): Promise<BulkWriteResult> {
|
|
1087
|
+
assertBulkFilters(operations);
|
|
1037
1088
|
const action = 'bulkUpdate' as ActionsApiList
|
|
1038
1089
|
return this.#executeWithAudit(action, collection, { count: operations.length }, async () => {
|
|
1039
|
-
const col =
|
|
1090
|
+
const col = requireCollection(collection, this.#tenant.id)
|
|
1040
1091
|
|
|
1041
1092
|
for (const operation of operations) {
|
|
1042
1093
|
if ('updateOne' in operation) {
|
|
@@ -1069,6 +1120,7 @@ class MongoRest {
|
|
|
1069
1120
|
@LogSlowQuery()
|
|
1070
1121
|
@CheckIfCollectionExists()
|
|
1071
1122
|
async countDocuments(collection: string, query: any = {}): Promise<number> {
|
|
1123
|
+
func.assertSafeFilter(query)
|
|
1072
1124
|
const action = 'countDocuments' as ActionsApiList
|
|
1073
1125
|
return this.#executeWithAudit(action, collection, { query }, async () => {
|
|
1074
1126
|
// ⚠️ must pass the filter — countDocuments() without args counts the whole collection
|
|
@@ -1275,18 +1327,26 @@ class MongoRest {
|
|
|
1275
1327
|
// here (`server.audit.redact`), whatever wrote the entry — including a
|
|
1276
1328
|
// direct `rest.audit.addActivities()` call. Never store a password,
|
|
1277
1329
|
// token or secret in clear, in `_audit_` or in the local file.
|
|
1278
|
-
const entries = activities.map((activity) =>
|
|
1279
|
-
|
|
1280
|
-
// `request` (headers, query string) is attacker-controlled too — it
|
|
1281
|
-
// carries `x-api-key`, `?token=…`, … just like a payload
|
|
1282
|
-
request: redactPayload(activity?.request),
|
|
1283
|
-
meta: redactPayload(activity?.meta),
|
|
1284
|
-
operation: {
|
|
1330
|
+
const entries = activities.map((activity) => {
|
|
1331
|
+
const operation: any = {
|
|
1285
1332
|
...activity?.operation,
|
|
1286
1333
|
input: redactPayload(activity?.operation?.input),
|
|
1287
1334
|
result: redactPayload(activity?.operation?.result),
|
|
1288
|
-
|
|
1289
|
-
|
|
1335
|
+
error: redactPayload(activity?.operation?.error),
|
|
1336
|
+
token: redactPayload(activity?.operation?.token),
|
|
1337
|
+
};
|
|
1338
|
+
if (typeof operation.error?.message === "string") {
|
|
1339
|
+
operation.error = { ...operation.error, message: redactText(operation.error.message) };
|
|
1340
|
+
}
|
|
1341
|
+
return {
|
|
1342
|
+
...activity,
|
|
1343
|
+
// `request` (headers, query string) is attacker-controlled too — it
|
|
1344
|
+
// carries `x-api-key`, `?token=…`, … just like a payload
|
|
1345
|
+
request: redactPayload(activity?.request),
|
|
1346
|
+
meta: redactPayload(activity?.meta),
|
|
1347
|
+
operation,
|
|
1348
|
+
};
|
|
1349
|
+
})
|
|
1290
1350
|
await this.db.collection(AUDIT_COLLECTION).insertMany(entries)
|
|
1291
1351
|
.then(e => e)
|
|
1292
1352
|
.catch(err => {
|
package/database/vars.ts
CHANGED
|
@@ -50,6 +50,9 @@ function varId(scope: string | null, ns: string, key: string): string {
|
|
|
50
50
|
|
|
51
51
|
function scopeToString(scope: any): string | null {
|
|
52
52
|
if (scope === undefined || scope === null || scope === "") return null;
|
|
53
|
+
if (typeof scope === "object" && !(scope instanceof ObjectId)) {
|
|
54
|
+
throw new AppError("[vars] scope must be a string, a number or an id", { code: "VALIDATION_ERROR", status: 400 });
|
|
55
|
+
}
|
|
53
56
|
return String(scope);
|
|
54
57
|
}
|
|
55
58
|
|
|
@@ -91,7 +94,15 @@ function validateMeta(def: Compiled | undefined, meta: any): Record<string, any>
|
|
|
91
94
|
/** Validate (and normalize) the scope value, using the namespace's `scope` field when declared. */
|
|
92
95
|
function validateScope(def: Compiled | undefined, scope: any): any {
|
|
93
96
|
if (scope === undefined || scope === null || scope === "") return null;
|
|
94
|
-
if (!def?.scope)
|
|
97
|
+
if (!def?.scope) {
|
|
98
|
+
// No declared scope: only a **primitive** may name one. Returning the raw
|
|
99
|
+
// value let a caller pass a Mongo operator (`{"$ne": null}`) that
|
|
100
|
+
// `findEntries` / `clear` then used as a filter — cross-scope read/delete.
|
|
101
|
+
if (typeof scope === "string") return scope;
|
|
102
|
+
if (typeof scope === "number" || typeof scope === "bigint") return String(scope);
|
|
103
|
+
if (typeof scope === "object" && scope instanceof ObjectId) return scope;
|
|
104
|
+
throw validationError(def?.namespace ?? "?", "scope must be a string or a number");
|
|
105
|
+
}
|
|
95
106
|
const schema = fieldToSchema(def.scope);
|
|
96
107
|
const { error, value } = schema!.validate(scope);
|
|
97
108
|
if (error) throw validationError(def.namespace, `${def.scope.name} — ${error.message}`);
|
|
@@ -164,7 +175,16 @@ function createVars(tenantId: string, boundScope?: any): TenantVars {
|
|
|
164
175
|
const findEntries = async (ns: string, opts?: VarsAllOptions): Promise<any[]> => {
|
|
165
176
|
const scope = resolveScope(ns, opts);
|
|
166
177
|
const where = opts?.where ?? {};
|
|
167
|
-
const metaFilter
|
|
178
|
+
const metaFilter: Record<string, any> = {};
|
|
179
|
+
for (const [field, value] of Object.entries(where)) {
|
|
180
|
+
if (field.startsWith("$")) throw validationError(ns, `invalid where field '${field}'`);
|
|
181
|
+
const primitive = value === null
|
|
182
|
+
|| ["string", "number", "boolean", "bigint"].includes(typeof value)
|
|
183
|
+
|| value instanceof Date
|
|
184
|
+
|| value instanceof ObjectId;
|
|
185
|
+
if (!primitive) throw validationError(ns, `invalid where value for '${field}'`);
|
|
186
|
+
metaFilter[`meta.${field}`] = value;
|
|
187
|
+
}
|
|
168
188
|
return await col().find({ ns, scope: scope.value, ...metaFilter, ...alive() }).toArray();
|
|
169
189
|
};
|
|
170
190
|
|
package/index.ts
CHANGED
|
@@ -6,9 +6,11 @@ import { useRest } from "./database/rest";
|
|
|
6
6
|
import { AppError } from "./lib/error";
|
|
7
7
|
import { Agent } from "./lib/agent";
|
|
8
8
|
import { agents } from "./lib/agents";
|
|
9
|
+
import { evaluateAgent } from "./lib/eval";
|
|
9
10
|
import { endpoints } from "./lib/endpoints";
|
|
10
11
|
import * as v from "joi";
|
|
11
12
|
import utils from "./utils";
|
|
13
|
+
import {ObjectId} from "mongodb"
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
|
|
@@ -46,5 +48,25 @@ export {
|
|
|
46
48
|
logger,
|
|
47
49
|
Agent,
|
|
48
50
|
agents,
|
|
51
|
+
evaluateAgent,
|
|
49
52
|
endpoints,
|
|
53
|
+
ObjectId
|
|
50
54
|
}
|
|
55
|
+
|
|
56
|
+
export type {
|
|
57
|
+
AgentMemoryContext,
|
|
58
|
+
AgentRecallHit,
|
|
59
|
+
AgentRecallQuery,
|
|
60
|
+
AgentRecallRecord,
|
|
61
|
+
AgentRunState,
|
|
62
|
+
AgentRunStore,
|
|
63
|
+
AgentSemanticRecallOptions,
|
|
64
|
+
AgentTraceEvent,
|
|
65
|
+
} from "./types/agent";
|
|
66
|
+
|
|
67
|
+
export type {
|
|
68
|
+
AgentEvalCase,
|
|
69
|
+
AgentEvalCaseReport,
|
|
70
|
+
AgentEvalGrader,
|
|
71
|
+
AgentEvalReport,
|
|
72
|
+
} from "./lib/eval";
|