@cap-js/agents 0.9.5 → 0.9.7

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 (61) hide show
  1. package/README.md +2 -131
  2. package/_i18n/messages_ar.properties +48 -0
  3. package/_i18n/messages_bg.properties +48 -0
  4. package/_i18n/messages_cs.properties +48 -0
  5. package/_i18n/messages_da.properties +48 -0
  6. package/_i18n/messages_de.properties +48 -0
  7. package/_i18n/messages_el.properties +48 -0
  8. package/_i18n/messages_en.properties +48 -0
  9. package/_i18n/messages_es.properties +48 -0
  10. package/_i18n/messages_es_MX.properties +48 -0
  11. package/_i18n/messages_fi.properties +48 -0
  12. package/_i18n/messages_fr.properties +48 -0
  13. package/_i18n/messages_he.properties +48 -0
  14. package/_i18n/messages_hr.properties +48 -0
  15. package/_i18n/messages_hu.properties +48 -0
  16. package/_i18n/messages_it.properties +48 -0
  17. package/_i18n/messages_ja.properties +48 -0
  18. package/_i18n/messages_kk.properties +48 -0
  19. package/_i18n/messages_ko.properties +48 -0
  20. package/_i18n/messages_ms.properties +48 -0
  21. package/_i18n/messages_nl.properties +48 -0
  22. package/_i18n/messages_no.properties +48 -0
  23. package/_i18n/messages_pl.properties +48 -0
  24. package/_i18n/messages_pt.properties +48 -0
  25. package/_i18n/messages_ro.properties +48 -0
  26. package/_i18n/messages_ru.properties +48 -0
  27. package/_i18n/messages_sh.properties +48 -0
  28. package/_i18n/messages_sk.properties +48 -0
  29. package/_i18n/messages_sl.properties +48 -0
  30. package/_i18n/messages_sv.properties +48 -0
  31. package/_i18n/messages_th.properties +48 -0
  32. package/_i18n/messages_tr.properties +48 -0
  33. package/_i18n/messages_uk.properties +48 -0
  34. package/_i18n/messages_vi.properties +48 -0
  35. package/_i18n/messages_zh_CN.properties +48 -0
  36. package/_i18n/messages_zh_TW.properties +48 -0
  37. package/cds-plugin.js +2 -0
  38. package/lib/agents/middleware/index.js +2 -0
  39. package/lib/agents/middleware/masking.js +124 -0
  40. package/lib/agents/middleware/remote-mcp.js +5 -3
  41. package/lib/compile.js +2 -6
  42. package/lib/masking/index.js +90 -0
  43. package/lib/masking/store.js +80 -0
  44. package/lib/masking/structured/findElements.js +459 -0
  45. package/lib/masking/structured/index.js +170 -0
  46. package/lib/masking/unstructured/dpi.js +84 -0
  47. package/lib/masking/unstructured/hana.js +64 -0
  48. package/lib/masking/unstructured/index.js +52 -0
  49. package/lib/preview/chat.html +66 -30
  50. package/lib/protocol/agent-card.js +2 -6
  51. package/lib/protocol/persistence/cleanup.js +13 -4
  52. package/lib/telemetry/chat-tracing.js +7 -2
  53. package/lib/telemetry/mlflow/tracing.js +3 -3
  54. package/lib/telemetry/span-masking.js +165 -0
  55. package/lib/telemetry/tool-tracing.js +4 -4
  56. package/lib/utils/markdown.js +2 -2
  57. package/lib/utils/utils.js +2 -34
  58. package/package.json +4 -2
  59. package/srv/handlers/graph-executor.js +52 -17
  60. package/srv/handlers/system-prompt.js +7 -0
  61. package/srv/handlers/tools.js +4 -3
@@ -0,0 +1,459 @@
1
+ import cds from "@sap/cds"
2
+
3
+ const PERSONAL_DATA_CACHE = Symbol.for("@cap-js/agents:personalDataPaths")
4
+
5
+ const STRING_TYPES = new Set(["cds.String", "cds.UUID", "cds.LargeString"])
6
+ const NUMERIC_TYPES = new Set(["cds.Integer", "cds.Integer64", "cds.Decimal", "cds.Double"])
7
+
8
+ const PD_TAGS = [
9
+ "@PersonalData.IsPotentiallyPersonal",
10
+ "@PersonalData.IsPotentiallySensitive",
11
+ "@Common.Masked",
12
+ ]
13
+
14
+ export function hasPersonalDataAnnotation(element) {
15
+ return (
16
+ PD_TAGS.some((a) => element[a] != null && element[a] !== false) ||
17
+ element["@PersonalData.FieldSemantics"] === "DataSubjectID" ||
18
+ element["@PersonalData.FieldSemantics"] === "UserID"
19
+ )
20
+ }
21
+
22
+ export function shouldHash(el) {
23
+ if (STRING_TYPES.has(el.type)) return true
24
+ if (NUMERIC_TYPES.has(el.type)) return !!(el.key || el._foreignKey4)
25
+ return false
26
+ }
27
+
28
+ // Returns the set of hashable @PersonalData element names for an entity.
29
+ // strictMasking=true: also includes @Common.Masked:false fields (traces must not expose them).
30
+ //
31
+ // Without a `model`, only top-level scalar/scalar-array elements are returned as plain
32
+ // names (used by join/nav/expand resolution helpers that key on single element names).
33
+ //
34
+ // With a `model`, complex-type elements are resolved recursively and their nested PII is
35
+ // returned as PATH ARRAYS (e.g. ["address","street"], ["address","geo","lat"]), and
36
+ // arrayed structs likewise (["contacts","email"]). Scalar-array PII (annotation on the
37
+ // arrayed element itself, e.g. `nicknames : many String`) is returned as a plain name.
38
+ // pseudonymizeData traverses path arrays and hashes scalar-array elements.
39
+ export function personalDataElements(entityDef, strictMasking = false, model = null) {
40
+ if (!entityDef?.elements) return new Set()
41
+ const result = new Set()
42
+ _collectPaths(entityDef, strictMasking, model, [], result, 0)
43
+ return result
44
+ }
45
+
46
+ function _collectPaths(entityDef, strictMasking, model, prefix, out, depth) {
47
+ if (!entityDef?.elements || depth > 20) return
48
+ for (const [name, el] of Object.entries(entityDef.elements)) {
49
+ if (!strictMasking && el["@Common.Masked"] === false) continue
50
+ const path = [...prefix, name]
51
+
52
+ // Scalar (String/UUID or numeric key/FK): hash when annotated PII.
53
+ if (shouldHash(el)) {
54
+ if (hasPersonalDataAnnotation(el)) out.add(path.length === 1 ? name : path)
55
+ continue
56
+ }
57
+
58
+ if (!model) continue // flat mode: only top-level scalars
59
+
60
+ // Resolve the element's structural type (named type, inline struct, or arrayed).
61
+ const resolved = _structOf(el, model)
62
+ if (resolved?.elements) {
63
+ _collectPaths(resolved, strictMasking, model, path, out, depth + 1)
64
+ } else if (resolved?.items) {
65
+ // Arrayed: struct items recurse; scalar items annotated on the arrayed element → plain path.
66
+ const itemStruct = _structOf(resolved.items, model)
67
+ if (itemStruct?.elements) {
68
+ _collectPaths(itemStruct, strictMasking, model, path, out, depth + 1)
69
+ } else if (hasPersonalDataAnnotation(el) && shouldHash(resolved.items)) {
70
+ out.add(path.length === 1 ? name : path)
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ // Resolve an element/type node to its structural definition:
77
+ // named type reference → model definition; inline struct/array → itself.
78
+ function _structOf(node, model) {
79
+ if (!node) return null
80
+ if (node.elements || node.items) return node
81
+ if (node.type) return model?.definitions?.[node.type] ?? null
82
+ return null
83
+ }
84
+
85
+ // Model-level memoization; invalidates automatically when the model object changes.
86
+ function cache(model, key, compute) {
87
+ model[PERSONAL_DATA_CACHE] ??= {}
88
+ if (model[PERSONAL_DATA_CACHE][key] !== undefined) return model[PERSONAL_DATA_CACHE][key]
89
+ return (model[PERSONAL_DATA_CACHE][key] = compute())
90
+ }
91
+
92
+ // Returns the `returns` type node of an action/function.
93
+ // Case-insensitive fallback handles tool names normalised by toolName() (lowercased).
94
+ export function actionReturnType(model, srv, actionName) {
95
+ const exact = model.definitions?.[`${srv.name}.${actionName}`]
96
+ if (exact) return exact.returns ?? null
97
+ const lower = `${srv.name}.${actionName}`.toLowerCase()
98
+ const key = Object.keys(model.definitions ?? {}).find((k) => k.toLowerCase() === lower)
99
+ return key ? (model.definitions[key].returns ?? null) : null
100
+ }
101
+
102
+ export function hasPersonalDataAnnotations(model, serviceName) {
103
+ return cache(model, `hasAny:${serviceName}`, () =>
104
+ Object.entries(model.definitions ?? {}).some(([name, def]) => {
105
+ if (!name.startsWith(serviceName + ".") && name !== serviceName) return false
106
+ if (!def.elements) return false
107
+ return Object.values(def.elements).some((el) => hasPersonalDataAnnotation(el))
108
+ }),
109
+ )
110
+ }
111
+
112
+ export function discoverElementsToBeMasked(model, srv, cql, strictMasking) {
113
+ if (!cql) return new Set()
114
+ return cache(model, `query:${srv.name}:${cql}:${strictMasking}`, () => {
115
+ let cqn
116
+ try {
117
+ cqn = cds.parse.cql(cql)
118
+ } catch {
119
+ return new Set()
120
+ }
121
+ return _discoverFromCqn(model, srv, cqn, strictMasking)
122
+ })
123
+ }
124
+
125
+ // Resolve { ref, as? } to its entity def + alias. Returns null when unresolvable.
126
+ function _resolveSource(model, srv, node) {
127
+ const ref = node?.ref
128
+ const raw = typeof ref?.[0] === "string" ? ref[0] : ref?.[0]?.id
129
+ if (!raw) return null
130
+ const localName = raw.includes(".") ? raw : `${srv.name}.${raw}`
131
+ const entityDef = model.definitions?.[localName] ?? model.definitions?.[raw]
132
+ if (!entityDef) return null
133
+ const alias = node.as ?? raw.split(".").pop()
134
+ return { alias, entityDef }
135
+ }
136
+
137
+ // Collect all source entities from a FROM clause into a Map keyed by alias.
138
+ // Handles: plain ref, JOIN ({ join, args }), subquery ({ SELECT }), union-in-FROM ({ SET }).
139
+ // The primary (leading) source is additionally registered under the key `null`, so
140
+ // callers can resolve unqualified refs and unqualified expand roots without knowing
141
+ // the alias — even in joins where the primary source carries an alias.
142
+ function _collectSources(model, srv, from, out = new Map()) {
143
+ if (!from) return out
144
+ if (Array.isArray(from.args)) {
145
+ for (const arg of from.args) _collectSources(model, srv, arg, out)
146
+ return out
147
+ }
148
+ if (from.SELECT) return _collectSources(model, srv, from.SELECT.from, out)
149
+ if (from.SET) {
150
+ for (const branch of from.SET.args ?? [])
151
+ if (branch.SELECT) _collectSources(model, srv, branch.SELECT.from, out)
152
+ return out
153
+ }
154
+ const src = _resolveSource(model, srv, from)
155
+ if (src) {
156
+ out.set(src.alias, src)
157
+ if (!out.has(null)) out.set(null, src) // first resolved source is the primary
158
+ }
159
+ return out
160
+ }
161
+
162
+ // Extract all ref arrays from xpr/func.args nodes (not into SELECT — see _extractSubselects).
163
+ function _extractRefs(node) {
164
+ if (!node) return []
165
+ if (Array.isArray(node)) return node.flatMap(_extractRefs)
166
+ if (typeof node !== "object") return []
167
+ if (node.SELECT) return []
168
+ if (node.ref) return [node.ref]
169
+ if (node.xpr) return _extractRefs(node.xpr)
170
+ if (node.args) return _extractRefs(node.args)
171
+ return []
172
+ }
173
+
174
+ // Extract all scalar subselect nodes ({ SELECT }) from xpr/func.args.
175
+ function _extractSubselects(node) {
176
+ if (!node) return []
177
+ if (Array.isArray(node)) return node.flatMap(_extractSubselects)
178
+ if (typeof node !== "object") return []
179
+ if (node.SELECT) return [node]
180
+ const found = []
181
+ if (node.xpr) found.push(..._extractSubselects(node.xpr))
182
+ if (node.args) found.push(..._extractSubselects(node.args))
183
+ return found
184
+ }
185
+
186
+ // True when a ref resolves to a PII element via annotatedByAlias or nav-path traversal.
187
+ function _isRefPii(ref, annotatedByAlias, primaryEntity, model) {
188
+ if (!ref?.length) return false
189
+ const element = ref[ref.length - 1]
190
+
191
+ // Unqualified: binds to whichever source has the element.
192
+ if (ref.length === 1) {
193
+ for (const annotated of annotatedByAlias.values()) if (annotated.has(element)) return true
194
+ return false
195
+ }
196
+
197
+ // Qualified: ref[0] is a table alias, or an association path on the primary source.
198
+ let annotated = annotatedByAlias.get(ref[0])
199
+ if (!annotated && primaryEntity) {
200
+ let entityDef = primaryEntity
201
+ for (let i = 0; i < ref.length - 1 && entityDef; i++) {
202
+ const assocEl = entityDef.elements?.[ref[i]]
203
+ entityDef = assocEl?.target ? model.definitions?.[assocEl.target] : undefined
204
+ }
205
+ if (entityDef) annotated = personalDataElements(entityDef)
206
+ }
207
+ return annotated?.has(element) ?? false
208
+ }
209
+
210
+ // Recursively walk expand columns under `expandCols` rooted at `entityDef`.
211
+ // Emits path arrays like ["author","contact","email"] into `out` for every PII leaf.
212
+ // assocPath: path segments accumulated so far (e.g. ["author","contact"]).
213
+ function _expandPiiPaths(model, srv, entityDef, expandCols, assocPath, strictMasking, out) {
214
+ if (!entityDef || !expandCols) return
215
+ const annotated = personalDataElements(entityDef, strictMasking)
216
+ for (const expandCol of expandCols) {
217
+ if (expandCol.SELECT && expandCol.as) {
218
+ if (_discoverFromCqn(model, srv, expandCol, strictMasking).size > 0)
219
+ out.add([...assocPath, expandCol.as])
220
+ continue
221
+ }
222
+ const colName = expandCol.ref?.[expandCol.ref.length - 1]
223
+ if (!colName) continue
224
+ if (expandCol.expand) {
225
+ const assocEl = entityDef.elements?.[colName]
226
+ const targetDef = assocEl?.target ? model.definitions?.[assocEl.target] : undefined
227
+ _expandPiiPaths(
228
+ model,
229
+ srv,
230
+ targetDef,
231
+ expandCol.expand,
232
+ [...assocPath, expandCol.as ?? colName],
233
+ strictMasking,
234
+ out,
235
+ )
236
+ } else if (annotated.has(colName)) {
237
+ out.add([...assocPath, expandCol.as ?? colName])
238
+ }
239
+ }
240
+ }
241
+
242
+ // Map a set of inner PII field names through the outer SELECT's columns.
243
+ // `byInnerName`: true → look up col.ref[last] (subquery rename), result key = col.as ?? innerName.
244
+ // false → look up col.as ?? col.ref[last] directly (union, no rename).
245
+ // SELECT * (or no columns) passes the inner set through unchanged.
246
+ function _mapThroughColumns(columns, innerPii, byInnerName) {
247
+ if (!columns || columns.some((c) => c === "*")) return innerPii
248
+ const result = new Set()
249
+ for (const col of columns) {
250
+ const lastRef = col.ref?.[col.ref.length - 1]
251
+ if (byInnerName) {
252
+ if (lastRef && innerPii.has(lastRef)) result.add(col.as ?? lastRef)
253
+ } else {
254
+ const name = col.as ?? lastRef
255
+ if (name && innerPii.has(name)) result.add(name)
256
+ }
257
+ }
258
+ return result
259
+ }
260
+
261
+ // Resolve a plain ref column to the PII result keys it contributes.
262
+ // Returns an array: [] none, [name] a scalar/scalar-array PII column, or one-or-more
263
+ // path arrays when the column is a complex/arrayed type carrying nested PII
264
+ // (e.g. SELECT address → [["address","street"], ["address","geo","lat"], ...]).
265
+ // `ctx` carries the per-query lookup state.
266
+ function _resolvePlainRef(col, ctx) {
267
+ const { model, sources, primaryEntity, strictMasking } = ctx
268
+ const ref = col.ref
269
+ const element = ref[ref.length - 1]
270
+
271
+ // Find the source entity the column binds to.
272
+ let entityDef
273
+ if (ref.length === 1) {
274
+ // Unqualified: binds to whichever source has the element.
275
+ for (const [alias, src] of sources) {
276
+ if (alias === null) continue
277
+ if (src.entityDef.elements?.[element]) {
278
+ entityDef = src.entityDef
279
+ break
280
+ }
281
+ }
282
+ } else {
283
+ // Qualified: ref[0] is a table alias, or an association path on the primary source.
284
+ entityDef = sources.get(ref[0])?.entityDef
285
+ if (!entityDef && primaryEntity) {
286
+ let walk = primaryEntity
287
+ for (let i = 0; i < ref.length - 1 && walk; i++) {
288
+ const assocEl = walk.elements?.[ref[i]]
289
+ walk = assocEl?.target ? model.definitions?.[assocEl.target] : undefined
290
+ }
291
+ entityDef = walk
292
+ }
293
+ }
294
+ const elDef = entityDef?.elements?.[element]
295
+ if (!elDef) return []
296
+ if (!strictMasking && elDef["@Common.Masked"] === false) return []
297
+
298
+ const outKey = col.as ?? element
299
+
300
+ // Scalar (incl. scalar array annotated on the element): single result key.
301
+ if (shouldHash(elDef)) {
302
+ return hasPersonalDataAnnotation(elDef) ? [outKey] : []
303
+ }
304
+
305
+ // Complex / arrayed type: collect nested PII as paths under the column's output key.
306
+ const nested = new Set()
307
+ _collectComplexPaths(elDef, strictMasking, model, [outKey], nested, 0)
308
+ return [...nested]
309
+ }
310
+
311
+ // Walk a single complex/arrayed element's type, emitting path arrays for nested PII.
312
+ function _collectComplexPaths(elDef, strictMasking, model, prefix, out, depth) {
313
+ if (depth > 20) return
314
+ const resolved = _structOf(elDef, model)
315
+ if (resolved?.elements) {
316
+ for (const [name, child] of Object.entries(resolved.elements)) {
317
+ if (!strictMasking && child["@Common.Masked"] === false) continue
318
+ const path = [...prefix, name]
319
+ if (shouldHash(child)) {
320
+ if (hasPersonalDataAnnotation(child)) out.add(path)
321
+ } else {
322
+ _collectComplexPaths(child, strictMasking, model, path, out, depth + 1)
323
+ }
324
+ }
325
+ } else if (resolved?.items) {
326
+ const itemStruct = _structOf(resolved.items, model)
327
+ if (itemStruct?.elements) {
328
+ _collectComplexPaths(
329
+ { elements: itemStruct.elements },
330
+ strictMasking,
331
+ model,
332
+ prefix,
333
+ out,
334
+ depth + 1,
335
+ )
336
+ } else if (hasPersonalDataAnnotation(elDef) && shouldHash(resolved.items)) {
337
+ out.add(prefix)
338
+ }
339
+ }
340
+ }
341
+
342
+ // Resolve the target entity def reached by walking a ref path from the sources.
343
+ // Unqualified expand root (author { ... }) binds to the primary source.
344
+ function _resolveExpandTarget(col, ctx) {
345
+ const { model, sources, primaryEntity } = ctx
346
+ const firstSeg = col.ref.length > 1 ? col.ref[0] : null
347
+ let targetDef =
348
+ firstSeg !== null ? (sources.get(firstSeg)?.entityDef ?? primaryEntity) : primaryEntity
349
+ for (const seg of col.ref) {
350
+ const assocEl = targetDef?.elements?.[seg]
351
+ targetDef = assocEl?.target ? model.definitions?.[assocEl.target] : undefined
352
+ }
353
+ return targetDef
354
+ }
355
+
356
+ function _discoverFromCqn(model, srv, cqn, strictMasking) {
357
+ // Top-level SET: union results from every branch.
358
+ if (cqn.SET) {
359
+ const all = new Set()
360
+ for (const branch of cqn.SET.args ?? [])
361
+ for (const f of _discoverFromCqn(model, srv, branch, strictMasking)) all.add(f)
362
+ return all
363
+ }
364
+
365
+ const from = cqn.SELECT?.from
366
+ const columns = cqn.SELECT?.columns
367
+
368
+ // SET in FROM (SELECT name FROM (Books UNION Authors)): union branch results,
369
+ // then map through outer columns by their output name (no rename at union level).
370
+ if (from?.SET) {
371
+ const innerPii = new Set()
372
+ for (const branch of from.SET.args ?? [])
373
+ for (const f of _discoverFromCqn(model, srv, branch, strictMasking)) innerPii.add(f)
374
+ return _mapThroughColumns(columns, innerPii, false)
375
+ }
376
+
377
+ // Subquery FROM: recurse into the inner SELECT, then map through outer columns
378
+ // by the inner (renamed) field name.
379
+ if (from?.SELECT) {
380
+ const innerPii = _discoverFromCqn(model, srv, from, strictMasking)
381
+ if (innerPii.size) return _mapThroughColumns(columns, innerPii, true)
382
+ }
383
+
384
+ const sources = _collectSources(model, srv, from)
385
+ const primaryEntity = sources.get(null)?.entityDef
386
+ // Flat sets (top-level scalar names) for join/nav column resolution.
387
+ const annotatedByAlias = new Map()
388
+ for (const [alias, { entityDef }] of sources) {
389
+ if (alias === null) continue // primary is also stored under its real alias
390
+ annotatedByAlias.set(alias, personalDataElements(entityDef, strictMasking))
391
+ }
392
+
393
+ const anyAnnotated = [...annotatedByAlias.values()].some((s) => s.size)
394
+ const hasNavigationOrExpr =
395
+ columns &&
396
+ columns.some((c) => {
397
+ if (!c || typeof c !== "object") return false
398
+ return (
399
+ (Array.isArray(c?.ref) && (c.ref.length > 1 || c.expand)) || c.xpr || c.func || c.SELECT
400
+ )
401
+ })
402
+ // A source may have PII only inside complex/arrayed elements (not caught by the flat
403
+ // set above) — recompute with the model to detect those before bailing out.
404
+ const anyDeepAnnotated =
405
+ anyAnnotated ||
406
+ [...sources].some(
407
+ ([alias, { entityDef }]) =>
408
+ alias !== null && personalDataElements(entityDef, strictMasking, model).size,
409
+ )
410
+ if (!anyDeepAnnotated && !hasNavigationOrExpr) return new Set()
411
+
412
+ // SELECT * — every source's annotated elements, resolving complex/arrayed types
413
+ // into path arrays so nested PII is masked too.
414
+ if (!columns || columns.some((c) => c === "*")) {
415
+ const all = new Set()
416
+ for (const [alias, { entityDef }] of sources) {
417
+ if (alias === null) continue
418
+ for (const n of personalDataElements(entityDef, strictMasking, model)) all.add(n)
419
+ }
420
+ return all
421
+ }
422
+
423
+ const ctx = { model, srv, strictMasking, sources, annotatedByAlias, primaryEntity }
424
+
425
+ const resultNames = new Set()
426
+ for (const col of columns) {
427
+ if (col.expand && col.ref?.length) {
428
+ // Expand: SELECT author { name } — emits path arrays e.g. ["author","name"]
429
+ const assocPath = col.as ? [col.as] : [...col.ref]
430
+ _expandPiiPaths(
431
+ model,
432
+ srv,
433
+ _resolveExpandTarget(col, ctx),
434
+ col.expand,
435
+ assocPath,
436
+ strictMasking,
437
+ resultNames,
438
+ )
439
+ } else if (col.ref?.length) {
440
+ // Plain ref: scalar → name; complex/arrayed → one or more path arrays.
441
+ for (const key of _resolvePlainRef(col, ctx)) resultNames.add(key)
442
+ } else if (col.SELECT && col.as) {
443
+ // Scalar subselect as column
444
+ if (_discoverFromCqn(model, srv, col, strictMasking).size > 0) resultNames.add(col.as)
445
+ } else if ((col.xpr || col.func) && col.as) {
446
+ // Expression / function: check extracted refs, then nested subselects
447
+ const byRef = _extractRefs(col).some((ref) =>
448
+ _isRefPii(ref, annotatedByAlias, primaryEntity, model),
449
+ )
450
+ const bySub =
451
+ !byRef &&
452
+ _extractSubselects(col).some(
453
+ (sub) => _discoverFromCqn(model, srv, sub, strictMasking).size > 0,
454
+ )
455
+ if (byRef || bySub) resultNames.add(col.as)
456
+ }
457
+ }
458
+ return resultNames
459
+ }
@@ -0,0 +1,170 @@
1
+ import cds from "@sap/cds"
2
+ import {
3
+ shouldHash,
4
+ personalDataElements,
5
+ discoverElementsToBeMasked,
6
+ hasPersonalDataAnnotations,
7
+ hasPersonalDataAnnotation,
8
+ actionReturnType,
9
+ } from "./findElements.js"
10
+
11
+ export { shouldHash, personalDataElements, discoverElementsToBeMasked, hasPersonalDataAnnotations }
12
+
13
+ /**
14
+ * Check whether masking is needed for a service — local model + remote MCP models.
15
+ */
16
+ export function needsMasking(serviceName) {
17
+ const srv = cds.services?.[serviceName]
18
+ const model = cds.context?.model ?? srv?.model
19
+ if (model && hasPersonalDataAnnotations(model, serviceName)) return true
20
+ for (const { serviceName: remoteName } of Object.values(cds.context?.__mcpDynamicTools ?? {})) {
21
+ const remoteSrv = cds.services?.[remoteName]
22
+ const remoteModel = remoteSrv?.model
23
+ if (remoteModel && hasPersonalDataAnnotations(remoteModel, remoteName)) return true
24
+ }
25
+ return false
26
+ }
27
+
28
+ export function pseudonymizeData(data, annotatedFields, session) {
29
+ if (!annotatedFields.size) return
30
+ const rows = Array.isArray(data) ? data : data ? [data] : []
31
+ for (const row of rows) {
32
+ if (!row || typeof row !== "object") continue
33
+ for (const field of annotatedFields) {
34
+ if (Array.isArray(field) && field.length > 1) {
35
+ // Multi-segment path: nested ({address:{street}}) or CAP-flattened ({address_street}).
36
+ const [first, ...rest] = field
37
+ if (first in row && row[first] != null && typeof row[first] === "object") {
38
+ pseudonymizeData(row[first], new Set([rest.length === 1 ? rest[0] : rest]), session)
39
+ } else {
40
+ const flat = field.join("_")
41
+ if (flat in row && row[flat] != null) {
42
+ const v = row[flat]
43
+ row[flat] = Array.isArray(v)
44
+ ? v.map((x) => (x == null ? x : session.pseudonymize(x, flat)))
45
+ : session.pseudonymize(v, flat)
46
+ }
47
+ }
48
+ } else {
49
+ // Plain name or single-element path array ["nicknames"].
50
+ const name = Array.isArray(field) ? field[0] : field
51
+ if (!(name in row) || row[name] == null) continue
52
+ const value = row[name]
53
+ // Scalar array — hash each element.
54
+ row[name] = Array.isArray(value)
55
+ ? value.map((v) => (v == null ? v : session.pseudonymize(v, name)))
56
+ : session.pseudonymize(value, name)
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ export function resolveArgs(value, session) {
63
+ if (!session?._hashToOriginal?.size) return value
64
+ if (typeof value === "string") return session.resolveText(value)
65
+ if (Array.isArray(value)) {
66
+ const mapped = value.map((v) => resolveArgs(v, session))
67
+ return mapped.every((v, i) => v === value[i]) ? value : mapped
68
+ }
69
+ if (value && typeof value === "object") {
70
+ let changed = false
71
+ const out = {}
72
+ for (const [k, v] of Object.entries(value)) {
73
+ const resolved = resolveArgs(v, session)
74
+ out[k] = resolved
75
+ if (resolved !== v) changed = true
76
+ }
77
+ return changed ? out : value
78
+ }
79
+ return value
80
+ }
81
+
82
+ // Walk a CDS type definition recursively alongside `data`, hashing PII leaves.
83
+ // Covers action/function return shapes: named type refs, structs, arrays, scalar arrays.
84
+ // Depth guard (20) prevents runaway on self-referential types.
85
+ function pseudonymizeByType(data, typeDef, propName, session, model, strictMasking, depth = 0) {
86
+ if (data == null || typeDef == null || depth > 20) return data
87
+
88
+ // Named type ref → resolve then recurse; unresolved scalar → hash if PII.
89
+ if (typeDef.type && !typeDef.elements && !typeDef.items) {
90
+ const resolved = model.definitions?.[typeDef.type]
91
+ if (resolved)
92
+ return pseudonymizeByType(data, resolved, propName, session, model, strictMasking, depth + 1)
93
+ if (shouldHash(typeDef) && hasPersonalDataAnnotation(typeDef)) {
94
+ if (!strictMasking && typeDef["@Common.Masked"] === false) return data
95
+ return session.pseudonymize(data, propName)
96
+ }
97
+ return data
98
+ }
99
+
100
+ // Array: `many String @PersonalData` carries the annotation on the arrayed node, not
101
+ // on items — detect and hash each scalar directly; otherwise recurse per item.
102
+ if (typeDef.items) {
103
+ if (!Array.isArray(data)) return data
104
+ const scalarPii =
105
+ hasPersonalDataAnnotation(typeDef) &&
106
+ !typeDef.items.elements &&
107
+ !typeDef.items.items &&
108
+ shouldHash(typeDef.items)
109
+ if (scalarPii && (strictMasking || typeDef["@Common.Masked"] !== false))
110
+ return data.map((item) => (item == null ? item : session.pseudonymize(item, propName)))
111
+ return data.map((item) =>
112
+ pseudonymizeByType(item, typeDef.items, propName, session, model, strictMasking, depth + 1),
113
+ )
114
+ }
115
+
116
+ // Struct → recurse into each element.
117
+ if (typeDef.elements) {
118
+ if (typeof data !== "object" || Array.isArray(data)) return data
119
+ for (const [name, elDef] of Object.entries(typeDef.elements)) {
120
+ if (!(name in data) || data[name] == null) continue
121
+ data[name] = pseudonymizeByType(
122
+ data[name],
123
+ elDef,
124
+ name,
125
+ session,
126
+ model,
127
+ strictMasking,
128
+ depth + 1,
129
+ )
130
+ }
131
+ return data
132
+ }
133
+
134
+ // Inline scalar leaf.
135
+ if (shouldHash(typeDef) && hasPersonalDataAnnotation(typeDef)) {
136
+ if (!strictMasking && typeDef["@Common.Masked"] === false) return data
137
+ return session.pseudonymize(data, propName)
138
+ }
139
+ return data
140
+ }
141
+
142
+ // Pseudonymize PII in a decoded tool result object (mutates in place).
143
+ // Queries: CQL-derived field discovery. Actions/functions: recursive type walk.
144
+ export function pseudonymizeToolResult({
145
+ decoded,
146
+ toolName,
147
+ cql,
148
+ strictMasking = false,
149
+ session,
150
+ srv,
151
+ model,
152
+ }) {
153
+ if (!decoded || !session || !srv || !model) return
154
+ const payload = decoded?.data ?? decoded?.result
155
+ if (payload == null) return
156
+
157
+ if (toolName === "query" || toolName.endsWith("_query")) {
158
+ const annotatedFields = discoverElementsToBeMasked(model, srv, cql, strictMasking)
159
+ if (!annotatedFields.size) return
160
+ pseudonymizeData(payload, annotatedFields, session)
161
+ return
162
+ }
163
+
164
+ // Action/function: walk return-type definition. Top-level scalar has no element
165
+ // name → use tool name as hash prefix.
166
+ const returnType = actionReturnType(model, srv, toolName)
167
+ if (!returnType) return
168
+ const key = decoded.data != null ? "data" : "result"
169
+ decoded[key] = pseudonymizeByType(payload, returnType, toolName, session, model, strictMasking)
170
+ }