cap-mcp-guard 0.3.0 → 0.3.1
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/lib/adapters/cap.js +30 -6
- package/lib/core/interceptor.js +108 -14
- package/lib/policy/config.js +30 -3
- package/package.json +1 -1
package/lib/adapters/cap.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('fs');
|
|
3
4
|
const path = require('path');
|
|
4
5
|
|
|
5
6
|
const { attachInterceptor } = require('../core/interceptor');
|
|
@@ -7,23 +8,42 @@ const { loadConfig, NOT_CONFIGURED_PREFIX } = require('../policy/config');
|
|
|
7
8
|
const { logAudit } = require('../audit/log');
|
|
8
9
|
const { exportSpan } = require('../otel/exporter');
|
|
9
10
|
|
|
11
|
+
// Versions before 0.3.0 read policy from this file instead of package.json. It is no
|
|
12
|
+
// longer read at all — see resolvePolicyDefinition()'s legacy-file check below, which
|
|
13
|
+
// exists specifically so an unmigrated project fails loudly instead of silently running
|
|
14
|
+
// with zero enforcement.
|
|
15
|
+
const LEGACY_YAML_FILENAME = 'cap-mcp-guard.yaml';
|
|
16
|
+
|
|
10
17
|
/**
|
|
11
18
|
* Loads the `"cap-mcp-guard"` key from the host CAP project's package.json.
|
|
12
19
|
* A missing package.json or a missing key is a valid "not configured yet"
|
|
13
20
|
* state — falls back to a pass-through PolicyDefinition (every entity is
|
|
14
|
-
* opt-in, so this is equivalent to no policy at all)
|
|
15
|
-
*
|
|
16
|
-
*
|
|
21
|
+
* opt-in, so this is equivalent to no policy at all), UNLESS a legacy
|
|
22
|
+
* `cap-mcp-guard.yaml` is sitting right there (see LEGACY_YAML_FILENAME): that
|
|
23
|
+
* almost certainly means an unmigrated pre-0.3.0 project, and starting up
|
|
24
|
+
* silently unenforced would be worse than refusing to start. A config that
|
|
25
|
+
* exists but fails to parse is a user mistake and is NOT swallowed either —
|
|
26
|
+
* it propagates so the bad config gets noticed rather than silently ignored.
|
|
17
27
|
*
|
|
18
28
|
* @param {object} cds the @sap/cds module (used only for cds.root)
|
|
19
29
|
*/
|
|
20
30
|
function resolvePolicyDefinition(cds) {
|
|
21
|
-
const
|
|
31
|
+
const root = cds.root || process.cwd();
|
|
32
|
+
const configPath = path.join(root, 'package.json');
|
|
22
33
|
|
|
23
34
|
try {
|
|
24
35
|
return loadConfig(configPath);
|
|
25
36
|
} catch (err) {
|
|
26
37
|
if (err.message.startsWith(NOT_CONFIGURED_PREFIX)) {
|
|
38
|
+
const legacyYamlPath = path.join(root, LEGACY_YAML_FILENAME);
|
|
39
|
+
if (fs.existsSync(legacyYamlPath)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`[cap-mcp-guard] found "${LEGACY_YAML_FILENAME}" at ${legacyYamlPath}, but this file has not been read ` +
|
|
42
|
+
'since 0.3.0 — config now lives under the "cap-mcp-guard" key in package.json. Move its contents there ' +
|
|
43
|
+
'(or delete the file, if you no longer want a policy) before starting the server. Refusing to start ' +
|
|
44
|
+
'rather than run with zero enforcement silently.'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
27
47
|
console.warn('[cap-mcp-guard] no config found, running in pass-through mode (no policies enforced)');
|
|
28
48
|
return { mode: 'observe', entities: {} };
|
|
29
49
|
}
|
|
@@ -46,7 +66,10 @@ function resolvePolicyDefinition(cds) {
|
|
|
46
66
|
* `"cap-mcp-guard"` key in the project's package.json (see resolvePolicyDefinition)
|
|
47
67
|
* @param {object|false} [options.audit] forwarded to logAudit()'s write
|
|
48
68
|
* options (`{ stdout, filePath }`); pass `false` to disable audit
|
|
49
|
-
* logging entirely.
|
|
69
|
+
* logging entirely. When omitted, falls back to the `"audit"` key parsed
|
|
70
|
+
* from package.json's `"cap-mcp-guard"` config (see lib/policy/config.js) —
|
|
71
|
+
* this is what lets `audit.filePath` be set through config alone, without
|
|
72
|
+
* hand-writing a registerCapMcpGuard() call.
|
|
50
73
|
* @param {object|false} [options.otel] forwarded to exportSpan()'s options
|
|
51
74
|
* (`{ tracer }`); pass `false` to skip OTel span export entirely. With
|
|
52
75
|
* no host-configured OTel SDK this is already a harmless no-op, so the
|
|
@@ -70,7 +93,8 @@ function resolvePolicyDefinition(cds) {
|
|
|
70
93
|
*/
|
|
71
94
|
function registerCapMcpGuard(cds, options = {}) {
|
|
72
95
|
const policyDefinition = options.policyDefinition || resolvePolicyDefinition(cds);
|
|
73
|
-
const { onDecision: userOnDecision,
|
|
96
|
+
const { onDecision: userOnDecision, otel } = options;
|
|
97
|
+
const audit = options.audit !== undefined ? options.audit : policyDefinition.audit;
|
|
74
98
|
|
|
75
99
|
const pseudonymSecret = options.pseudonymSecret || process.env.CAP_MCP_GUARD_PSEUDONYM_SECRET;
|
|
76
100
|
const usesPseudonymize = Object.values(policyDefinition.entities).some(
|
package/lib/core/interceptor.js
CHANGED
|
@@ -99,6 +99,67 @@ function applyPseudonymize(results, fieldsToPseudonymize, secret) {
|
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Finds the association/composition elements on a CAP entity definition, along with the
|
|
104
|
+
* (already service-qualified) entity name each one targets — e.g. for `Employees.department`,
|
|
105
|
+
* `{ name: 'department', targetEntity: 'CatalogService.Departments' }`. That target name is
|
|
106
|
+
* exactly what a request against `Departments` directly would resolve via resolveEntity(),
|
|
107
|
+
* so the same `policyDefinition.entities` keys apply to nested rows without any separate
|
|
108
|
+
* EDM/$metadata resolution — CAP's own compiled model already carries it.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} target req.target — a linked CSN entity definition, or undefined
|
|
111
|
+
* @returns {{name: string, targetEntity: string}[]}
|
|
112
|
+
*/
|
|
113
|
+
function findAssociationElements(target) {
|
|
114
|
+
if (!target || !target.elements) return [];
|
|
115
|
+
|
|
116
|
+
return Object.entries(target.elements)
|
|
117
|
+
.filter(([, el]) => el && (el.type === 'cds.Association' || el.type === 'cds.Composition'))
|
|
118
|
+
.map(([name, el]) => ({
|
|
119
|
+
name,
|
|
120
|
+
targetEntity: (el._target && el._target.name) || el.target
|
|
121
|
+
}))
|
|
122
|
+
.filter((assoc) => assoc.targetEntity);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Applies each expanded association's own policy (mask/pseudonymize) to the nested
|
|
127
|
+
* row(s) hanging off of it — one level deep, matching what `$expand` actually returns.
|
|
128
|
+
* Mutates `results` in place, same convention as applyMask()/applyPseudonymize(). A no-op
|
|
129
|
+
* when the request's entity has no associations, or none of them were expanded (the nav
|
|
130
|
+
* property is simply absent from the row), or the target entity has no policy configured.
|
|
131
|
+
*
|
|
132
|
+
* @param {object|object[]} results
|
|
133
|
+
* @param {object} target req.target — see findAssociationElements()
|
|
134
|
+
* @param {object} policyDefinition see lib/policy/config.js
|
|
135
|
+
* @param {string} [pseudonymSecret] forwarded to applyPseudonymize() for nested fields
|
|
136
|
+
*/
|
|
137
|
+
function applyNestedPolicy(results, target, policyDefinition, pseudonymSecret) {
|
|
138
|
+
const associations = findAssociationElements(target);
|
|
139
|
+
if (!associations.length) return;
|
|
140
|
+
|
|
141
|
+
const rows = Array.isArray(results) ? results : [results];
|
|
142
|
+
|
|
143
|
+
for (const row of rows) {
|
|
144
|
+
if (!row || typeof row !== 'object') continue;
|
|
145
|
+
|
|
146
|
+
for (const { name, targetEntity } of associations) {
|
|
147
|
+
const navValue = row[name];
|
|
148
|
+
if (navValue === undefined || navValue === null) continue;
|
|
149
|
+
|
|
150
|
+
const entityConfig = policyDefinition.entities[targetEntity];
|
|
151
|
+
if (!entityConfig) continue;
|
|
152
|
+
|
|
153
|
+
if (entityConfig.mask && entityConfig.mask.length > 0) {
|
|
154
|
+
applyMask(navValue, entityConfig.mask);
|
|
155
|
+
}
|
|
156
|
+
if (entityConfig.pseudonymize && entityConfig.pseudonymize.length > 0) {
|
|
157
|
+
applyPseudonymize(navValue, entityConfig.pseudonymize, pseudonymSecret);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
102
163
|
/**
|
|
103
164
|
* Attaches request interception to a CAP service instance, producing a
|
|
104
165
|
* guard context (see context.js) for every request that passes through it.
|
|
@@ -134,9 +195,47 @@ function attachInterceptor(srv, options = {}) {
|
|
|
134
195
|
const { onContext, policyDefinition, onDecision, now, pseudonymSecret } = options;
|
|
135
196
|
const clockDeps = now ? { now } : {};
|
|
136
197
|
|
|
198
|
+
function buildRequestContext(req, extra) {
|
|
199
|
+
return buildContext(
|
|
200
|
+
{
|
|
201
|
+
...extractAgentInfo(req),
|
|
202
|
+
...extractTraceContext(req),
|
|
203
|
+
entity: resolveEntity(req),
|
|
204
|
+
operation: resolveOperation(req),
|
|
205
|
+
tenant: req.tenant,
|
|
206
|
+
user: req.user && req.user.id,
|
|
207
|
+
session: req.id,
|
|
208
|
+
...extra
|
|
209
|
+
},
|
|
210
|
+
clockDeps
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
137
214
|
srv.before('*', (req) => {
|
|
138
215
|
if (!req) return;
|
|
139
216
|
req._mcpGuardStartedAt = process.hrtime.bigint();
|
|
217
|
+
|
|
218
|
+
if (!policyDefinition) return;
|
|
219
|
+
|
|
220
|
+
// allowTools is a gate, not a masking rule — it has to be enforced before the
|
|
221
|
+
// query runs, not after. rowCount/durationMs aren't known yet, but `allowed`
|
|
222
|
+
// never depends on either, so this decision is already final.
|
|
223
|
+
const context = buildRequestContext(req, {});
|
|
224
|
+
const decision = evaluate(context, policyDefinition);
|
|
225
|
+
|
|
226
|
+
if (decision.mode === 'enforce' && !decision.allowed) {
|
|
227
|
+
// req.reject() throws in real CAP requests, so srv.after('*') below never runs
|
|
228
|
+
// for a denied request — report it here instead, or the denial would never be
|
|
229
|
+
// audited/traced at all.
|
|
230
|
+
if (typeof onContext === 'function') onContext(context, req);
|
|
231
|
+
if (typeof onDecision === 'function') onDecision(decision, context, req);
|
|
232
|
+
|
|
233
|
+
if (typeof req.reject === 'function') {
|
|
234
|
+
req.reject(403, decision.reason);
|
|
235
|
+
} else {
|
|
236
|
+
throw new Error(decision.reason);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
140
239
|
});
|
|
141
240
|
|
|
142
241
|
srv.after('*', (results, req) => {
|
|
@@ -147,20 +246,7 @@ function attachInterceptor(srv, options = {}) {
|
|
|
147
246
|
? undefined
|
|
148
247
|
: Number(process.hrtime.bigint() - startedAt) / 1e6;
|
|
149
248
|
|
|
150
|
-
const context =
|
|
151
|
-
{
|
|
152
|
-
...extractAgentInfo(req),
|
|
153
|
-
...extractTraceContext(req),
|
|
154
|
-
entity: resolveEntity(req),
|
|
155
|
-
operation: resolveOperation(req),
|
|
156
|
-
tenant: req.tenant,
|
|
157
|
-
user: req.user && req.user.id,
|
|
158
|
-
session: req.id,
|
|
159
|
-
rowCount: resolveRowCount(results),
|
|
160
|
-
durationMs
|
|
161
|
-
},
|
|
162
|
-
clockDeps
|
|
163
|
-
);
|
|
249
|
+
const context = buildRequestContext(req, { rowCount: resolveRowCount(results), durationMs });
|
|
164
250
|
|
|
165
251
|
if (typeof onContext === 'function') {
|
|
166
252
|
onContext(context, req);
|
|
@@ -177,6 +263,14 @@ function attachInterceptor(srv, options = {}) {
|
|
|
177
263
|
applyPseudonymize(results, decision.fieldsToPseudonymize, pseudonymSecret);
|
|
178
264
|
}
|
|
179
265
|
|
|
266
|
+
if (decision.mode === 'enforce' && decision.rowLimitExceeded && Array.isArray(results)) {
|
|
267
|
+
results.length = decision.maxRows;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (decision.mode === 'enforce') {
|
|
271
|
+
applyNestedPolicy(results, req.target, policyDefinition, pseudonymSecret);
|
|
272
|
+
}
|
|
273
|
+
|
|
180
274
|
if (typeof onDecision === 'function') {
|
|
181
275
|
onDecision(decision, context, req);
|
|
182
276
|
}
|
package/lib/policy/config.js
CHANGED
|
@@ -14,6 +14,31 @@ function typeOf(value) {
|
|
|
14
14
|
return typeof value;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Validates the optional top-level `"audit"` key — the same shape
|
|
19
|
+
* lib/audit/log.js's writeAuditEntry() options take (`{ stdout, filePath }`).
|
|
20
|
+
* Exposing it in package.json is what lets audit-log persistence be turned on
|
|
21
|
+
* through config alone, without hand-writing a registerCapMcpGuard() call
|
|
22
|
+
* (see lib/adapters/cap.js, which forwards this to onDecision's audit option).
|
|
23
|
+
*/
|
|
24
|
+
function validateAuditConfig(raw) {
|
|
25
|
+
if (raw === undefined) return undefined;
|
|
26
|
+
|
|
27
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
28
|
+
throw new Error(`"audit" must be a mapping, got ${typeOf(raw)}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { filePath, stdout } = raw;
|
|
32
|
+
if (filePath !== undefined && typeof filePath !== 'string') {
|
|
33
|
+
throw new Error(`"audit.filePath" must be a string, got ${typeOf(filePath)}`);
|
|
34
|
+
}
|
|
35
|
+
if (stdout !== undefined && typeof stdout !== 'boolean') {
|
|
36
|
+
throw new Error(`"audit.stdout" must be a boolean, got ${typeOf(stdout)}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { ...(filePath !== undefined && { filePath }), ...(stdout !== undefined && { stdout }) };
|
|
40
|
+
}
|
|
41
|
+
|
|
17
42
|
function validatePseudonymizeEntry(entityName, entry) {
|
|
18
43
|
if (typeof entry === 'string') {
|
|
19
44
|
return { field: entry, type: 'opaque' };
|
|
@@ -91,7 +116,7 @@ function validateEntityConfig(name, raw) {
|
|
|
91
116
|
* @param {object} [opts]
|
|
92
117
|
* @param {string} [opts.source] label used in error messages (defaults to
|
|
93
118
|
* the conventional file name; loadConfig() passes the real path instead)
|
|
94
|
-
* @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined) }}
|
|
119
|
+
* @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined), audit: (object|undefined) }}
|
|
95
120
|
*/
|
|
96
121
|
function parseConfig(raw, opts = {}) {
|
|
97
122
|
const { source = 'package.json' } = opts;
|
|
@@ -122,7 +147,9 @@ function parseConfig(raw, opts = {}) {
|
|
|
122
147
|
throw new Error(`"users" must be an array, got ${typeOf(raw.users)}`);
|
|
123
148
|
}
|
|
124
149
|
|
|
125
|
-
|
|
150
|
+
const audit = validateAuditConfig(raw.audit);
|
|
151
|
+
|
|
152
|
+
return { mode: raw.mode, entities, services: raw.services, users: raw.users, audit };
|
|
126
153
|
}
|
|
127
154
|
|
|
128
155
|
/**
|
|
@@ -133,7 +160,7 @@ function parseConfig(raw, opts = {}) {
|
|
|
133
160
|
* with a single startsWith() check and fall back to pass-through mode.
|
|
134
161
|
*
|
|
135
162
|
* @param {string} packageJsonPath
|
|
136
|
-
* @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined) }}
|
|
163
|
+
* @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined), audit: (object|undefined) }}
|
|
137
164
|
*/
|
|
138
165
|
function loadConfig(packageJsonPath) {
|
|
139
166
|
let content;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cap-mcp-guard",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "A CDS plugin — the trust layer for AI agents accessing SAP CAP business data — request interception, policy enforcement, field masking/pseudonymization, and OpenTelemetry-native observability for MCP-enabled applications.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"author": "Furkan Aksoy <furkanaksy838@gmail.com>",
|