@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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/database/field.crypto.ts +38 -13
  4. package/database/mongodbadapter.ts +85 -25
  5. package/database/vars.ts +22 -2
  6. package/index.ts +22 -0
  7. package/lib/agent.ts +707 -79
  8. package/lib/agentMemory.ts +199 -0
  9. package/lib/agents.ts +133 -52
  10. package/lib/attachment.ts +24 -3
  11. package/lib/define.ts +5 -1
  12. package/lib/eval.ts +123 -0
  13. package/lib/files.ts +21 -15
  14. package/lib/jsonSchema.ts +8 -0
  15. package/lib/mcp.ts +52 -30
  16. package/lib/messages.ts +202 -19
  17. package/lib/partialJson.ts +79 -0
  18. package/lib/providers.ts +26 -2
  19. package/lib/redact.ts +11 -0
  20. package/lib/tracing.ts +20 -0
  21. package/lib/zip.ts +23 -3
  22. package/package.json +4 -5
  23. package/server/agents.ts +66 -1
  24. package/server/api.ts +302 -20
  25. package/server/boot.ts +13 -4
  26. package/server/hono.ts +146 -6
  27. package/tests/access-log-body.test.ts +253 -0
  28. package/tests/agent-checkpoint.test.ts +195 -0
  29. package/tests/agent-hardening.test.ts +238 -0
  30. package/tests/agent-memory.test.ts +175 -1
  31. package/tests/agent-stream-advanced.test.ts +246 -0
  32. package/tests/agent-tracing-eval.test.ts +219 -0
  33. package/tests/agent-working-memory.test.ts +7 -4
  34. package/tests/agent.test.ts +54 -3
  35. package/tests/agents-api.test.ts +21 -0
  36. package/tests/api-external-guards.test.ts +304 -0
  37. package/tests/attachment.test.ts +63 -0
  38. package/tests/audit-redact.test.ts +26 -1
  39. package/tests/field-encryption.test.ts +47 -3
  40. package/tests/filters-hardening.test.ts +81 -0
  41. package/tests/fixtures/ai-tenant/ai/agents/support/billing.agent.ts +9 -0
  42. package/tests/fixtures/ai-tenant/ai/agents/support/billing.tool.ts +9 -0
  43. package/tests/fixtures/ai-tenant/ai/agents/support.agent.ts +9 -0
  44. package/tests/fixtures/ai-tenant/ai/agents/support.tool.ts +9 -0
  45. package/tests/fixtures/ai-tenant/ai/mcp/resources/readme.resource.ts +9 -0
  46. package/tests/fixtures/ai-tenant/ai/mcp/tools/echo.tool.ts +9 -0
  47. package/tests/fixtures/fake-provider.ts +33 -0
  48. package/tests/fixtures/tool-tenant/ai/agents/report.agent.ts +12 -0
  49. package/tests/fixtures/tool-tenant/ai/agents/report.tool.ts +12 -0
  50. package/tests/fixtures/vars-tenant/vars/open.var.ts +18 -0
  51. package/tests/hono.test.ts +81 -1
  52. package/tests/jwt-tenant.test.ts +165 -0
  53. package/tests/logger.test.ts +62 -10
  54. package/tests/messages.test.ts +88 -32
  55. package/tests/tool-runtime.test.ts +291 -0
  56. package/tests/tool-types.ts +33 -0
  57. package/tests/upload.test.ts +175 -3
  58. package/tests/vars-api.test.ts +73 -1
  59. package/tests/vars.test.ts +1 -1
  60. package/types/agent.d.ts +386 -4
  61. package/types/config.d.ts +56 -2
  62. package/types/file.d.ts +1 -1
  63. package/types/mcp.d.ts +2 -2
  64. package/utils/func.ts +20 -0
  65. 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
@@ -225,4 +225,4 @@ The server mounts **Socket.io** at `/socket.io/`. Route handlers receive the `io
225
225
 
226
226
  ## License
227
227
 
228
- See the parent repository for the project license.
228
+ [MIT License](./LICENSE) — SPDX: `MIT`.
@@ -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.decrypt(v, true);
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.decrypt(v, true));
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 = getCollection(collection, this.#tenant.id) as Collection
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 = getCollection(collection, this.#tenant.id) as Collection
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 = getCollection(collection, this.#tenant.id) as Collection
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 = getCollection(collection, this.#tenant.id) as Collection
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 = getCollection(collection, this.#tenant.id) as Collection
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 = getCollection(collection, this.#tenant.id) as Collection
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 = getCollection(collection, this.#tenant.id) as Collection
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 = getCollection(collection, this.#tenant.id) as Collection
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 = getCollection(collection, this.#tenant.id) as Collection
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 = getCollection(collection, this.#tenant.id) as Collection
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 = getCollection(collection, this.#tenant.id) as Collection
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 = getCollection(collection, this.#tenant.id) as Collection
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 = getCollection(collection, this.#tenant.id) as Collection
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 = getCollection(collection, this.#tenant.id) as Collection
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
- ...activity,
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) return 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 = Object.fromEntries(Object.entries(where).map(([k, v]) => [`meta.${k}`, v]));
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";