@cap-js/agents 0.9.6 → 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.
- package/README.md +2 -131
- package/_i18n/messages_ar.properties +48 -0
- package/_i18n/messages_bg.properties +48 -0
- package/_i18n/messages_cs.properties +48 -0
- package/_i18n/messages_da.properties +48 -0
- package/_i18n/messages_de.properties +48 -0
- package/_i18n/messages_el.properties +48 -0
- package/_i18n/messages_en.properties +48 -0
- package/_i18n/messages_es.properties +48 -0
- package/_i18n/messages_es_MX.properties +48 -0
- package/_i18n/messages_fi.properties +48 -0
- package/_i18n/messages_fr.properties +48 -0
- package/_i18n/messages_he.properties +48 -0
- package/_i18n/messages_hr.properties +48 -0
- package/_i18n/messages_hu.properties +48 -0
- package/_i18n/messages_it.properties +48 -0
- package/_i18n/messages_ja.properties +48 -0
- package/_i18n/messages_kk.properties +48 -0
- package/_i18n/messages_ko.properties +48 -0
- package/_i18n/messages_ms.properties +48 -0
- package/_i18n/messages_nl.properties +48 -0
- package/_i18n/messages_no.properties +48 -0
- package/_i18n/messages_pl.properties +48 -0
- package/_i18n/messages_pt.properties +48 -0
- package/_i18n/messages_ro.properties +48 -0
- package/_i18n/messages_ru.properties +48 -0
- package/_i18n/messages_sh.properties +48 -0
- package/_i18n/messages_sk.properties +48 -0
- package/_i18n/messages_sl.properties +48 -0
- package/_i18n/messages_sv.properties +48 -0
- package/_i18n/messages_th.properties +48 -0
- package/_i18n/messages_tr.properties +48 -0
- package/_i18n/messages_uk.properties +48 -0
- package/_i18n/messages_vi.properties +48 -0
- package/_i18n/messages_zh_CN.properties +48 -0
- package/_i18n/messages_zh_TW.properties +48 -0
- package/cds-plugin.js +2 -0
- package/lib/agents/middleware/index.js +2 -0
- package/lib/agents/middleware/masking.js +124 -0
- package/lib/agents/middleware/remote-mcp.js +5 -3
- package/lib/masking/index.js +90 -0
- package/lib/masking/store.js +80 -0
- package/lib/masking/structured/findElements.js +459 -0
- package/lib/masking/structured/index.js +170 -0
- package/lib/masking/unstructured/dpi.js +84 -0
- package/lib/masking/unstructured/hana.js +64 -0
- package/lib/masking/unstructured/index.js +52 -0
- package/lib/preview/chat.html +66 -30
- package/lib/protocol/persistence/cleanup.js +13 -4
- package/lib/telemetry/chat-tracing.js +7 -2
- package/lib/telemetry/mlflow/tracing.js +3 -3
- package/lib/telemetry/span-masking.js +165 -0
- package/lib/telemetry/tool-tracing.js +4 -4
- package/lib/utils/markdown.js +2 -2
- package/package.json +3 -1
- package/srv/handlers/graph-executor.js +52 -17
- package/srv/handlers/system-prompt.js +7 -0
|
@@ -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
|
+
}
|