cap-mcp-guard 0.2.0 → 0.3.0

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 CHANGED
@@ -1,164 +1,230 @@
1
- # cap-mcp-guard
2
-
3
- CAP MCP Guard is the trust layer for AI agents accessing SAP CAP business data — request interception, policy enforcement, field masking, and OpenTelemetry-native observability for MCP-enabled applications.
4
-
5
- Today, organizations have only two options when exposing SAP CAP business data to AI agents: grant unrestricted access or deny access completely. CAP MCP Guard introduces a third option controlled, observable, and policy-driven access, without requiring developers to hand-write authorization, masking, and audit logic for every entity.
6
-
7
- We give CAP developers a standardized, reusable way to enforce AI-agent security, observability, and policy without hand-writing that logic for every entity. Convention over implementation, in the same spirit as CAP itself.
8
-
9
- ## Why
10
-
11
- - **Field exposure.** When AI agents connect to CAP entities over MCP, sensitive fields (`CreditCardNo`, `Salary`, national ID numbers, ...) are visible to the agent unless someone filters them by hand, entity by entity.
12
- - **No visibility.** Which agent accessed which entity, when, how many rows, and how long it took isn't logged anywhere standard. There's no answer when someone asks for an audit trail.
13
- - **Excessive trust.** Even a "read-only" agent can technically reach every action/function a service exposes through the MCP layer, unless something enforces otherwise. Relying on the agent to "behave" isn't a control.
14
- - **Enterprise distrust.** Companies want AI agents connected to business data, but security teams block it because nobody can prove what the agent will actually do. CAP MCP Guard is the third option between "wide open" and "no access at all."
15
-
16
- ## How it works
17
-
18
- ```
19
- AI Request → Intercept → Evaluate Policy → Mask → Execute → Audit → Trace
20
- ```
21
-
22
- ```
23
- Claude / Joule / Copilot / Custom Agent
24
-
25
- Any MCP Runtime
26
- (gavdilabs/cap-mcp-plugin, a custom runtime, a future official SAP
27
- solution it doesn't matter which)
28
-
29
- ┌─────────────────────────┐
30
- cap-mcp-guard │
31
- │ │
32
- lib/core/
33
- ├─ interceptor.js → attaches to CAP's srv.before/srv.after hooks
34
- └─ context.js → builds an OTel gen_ai.*-shaped, framework-
35
- agnostic request context
36
-
37
- lib/policy/ knows nothing about CAP. Plain JS: Context in,
38
- ├─ config.js Decision out.
39
- ├─ evaluator.js │
40
- └─ masking.js
41
-
42
- lib/audit/
43
- │ └─ log.js → Context + Decision → structured JSON log line
44
- │ │
45
- │ lib/otel/
46
- │ └─ exporter.js │ → Context + Decision → a real OTel span
47
- │ │
48
- │ lib/adapters/
49
- │ └─ cap.js │ → the ONE place that knows @sap/cds.
50
- │ │ cds-plugin.js calls this.
51
- └─────────────────────────┘
52
-
53
- CAP Service
54
- ```
55
-
56
- `lib/policy/` and `lib/core/context.js` never import `@sap/cds` — they only ever see a plain `Context` object and a plain `PolicyDefinition` object, regardless of where either one came from. That's what lets `cap-mcp-guard.yaml` be swapped out for a future CDS-annotation-based source later without touching the engine itself.
57
-
58
- ## Install
59
-
60
- ```bash
61
- npm install --save-dev cap-mcp-guard
62
- ```
63
-
64
- CAP auto-discovers `cds-plugin.js` the moment the package is a dependency of your project — no wiring required. Drop a `cap-mcp-guard.yaml` in your project root and it's picked up automatically the next time your CAP server starts.
65
-
66
- ## Configure
67
-
68
- ```yaml
69
- # cap-mcp-guard.yaml
70
- mode: enforce # or: observe (dry-run — logs/traces what would happen, blocks nothing)
71
-
72
- entities:
73
- Orders:
74
- mask:
75
- - CreditCard
76
- - Salary
77
- maxRows: 100
78
- allowTools:
79
- - ReadOrders
80
-
81
- Customers:
82
- mask:
83
- - Email
84
- - Phone
85
- ```
86
-
87
- - Entities not listed here are fully accessible — this is opt-in by design; you don't have to configure every entity up front.
88
- - No config file at all? The guard runs in pass-through mode (a `console.warn` tells you so) rather than crashing your server.
89
- - A config file that exists but fails to parse *does* fail loudly a broken config shouldn't fail silently.
90
-
91
- ## What you get, per request
92
-
93
- - **Masking** — in `enforce` mode, fields listed under `mask` are replaced with `'***MASKED***'` in the real response. In `observe` mode nothing is touched; the guard only computes what *would* happen.
94
- - **Audit log** — every request produces a structured JSON line (Context + Decision), to stdout and/or a file you choose.
95
- - **OpenTelemetry spans** every request also becomes a real span via `@opentelemetry/api`. If your app already has an OTel SDK configured (any OTLP-compatible backend — Grafana, Jaeger, Datadog, SAP Cloud Logging), the guard's spans just show up there, correctly linked into the caller's trace via W3C Trace Context (`traceparent`/`tracestate`) when present — no extra mapping needed, because the context schema was built against OTel's GenAI semantic conventions (`gen_ai.*`) from the start.
96
-
97
- All three run independently and can each be disabled per-call (`audit: false`, `otel: false`) if you're wiring `registerCapMcpGuard` yourself instead of relying on auto-discovery.
98
-
99
- ## OData
100
-
101
- `registerCapMcpGuard` hooks CAP's own `before`/`after` service events, which only exists inside a Node CAP process. A lot of business data reaches an MCP agent over OData without ever passing through those hooks — a plain OData V2/V4 service, a CAP Java service, or an on-prem SAP Gateway/S/4HANA system fronted by a proxy route. `odataMcpGuard` runs the same Context → Decision → mask/audit/OTel pipeline against raw OData HTTP traffic instead, using one `cap-mcp-guard.yaml`:
102
-
103
- ```js
104
- const express = require('express');
105
- const { odataMcpGuard } = require('cap-mcp-guard');
106
-
107
- const app = express();
108
- app.use('/odata', odataMcpGuard(), proxyToYourODataBackend);
109
- ```
110
-
111
- - Mount it in front of any route that responds via `res.json(body)` — your own OData handler, or a reverse-proxy route rewriting a remote OData service's response.
112
- - Entity names are resolved from the URL path (`/odata/v4/browse/Books(201)` → `Books`), so `cap-mcp-guard.yaml` keys entities by the same name whichever adapter is in front of them. HTTP methods map onto the same operation vocabulary CAP requests use (`GET`→`READ`, `POST`→`CREATE`, `PUT`/`PATCH`→`UPDATE`, `DELETE`→`DELETE`), so `allowTools` rules are portable too.
113
- - **`$expand` is masked too.** `?$expand=author` on a `Books` read pulls the related `Author` row inline without any extra config, its fields are masked under an `author` entity entry; nested `$expand=genre($expand=parent)` is masked at every level. One audit/OTel entry is produced per entity in the response (root + each expanded nav), not just one per HTTP request.
114
- - **Pass real `$metadata` for exact entity resolution.** By default, entity names are guessed from the URL/nav-property text itself (`Books(201)/author` → `author`, not the real target entity). Pass `metadataXml` (an already-fetched `$metadata` EDMX/CSDL document this module never fetches it itself) or a pre-parsed `edm` (see `lib/core/edm.js`), and both deep paths and `$expand` resolve through the real navigation properties instead (`Books(201)/author` → `Authors`):
115
-
116
- ```js
117
- const metadataXml = fs.readFileSync('./service.edmx', 'utf8'); // or fetch it once at startup
118
- app.use('/odata', odataMcpGuard({ metadataXml }), proxyToYourODataBackend);
119
- ```
120
-
121
- - **`$batch` is decoded too, both wire formats** each sub-request inside a batch is masked/audited/traced individually, exactly like a standalone request:
122
- - **OData V4 JSON batch** (`{ requests: [...] }` request / `{ responses: [...] }` response) needs `req.body` to already be the parsed request JSON — mount a JSON body-parser (`express.json()`) before this middleware.
123
- - **Classic OData V2 `multipart/mixed` batch** (including changesets — grouped write operations) needs `req.body` to already be the *raw* request body, and goes out via `res.send(rawBody)` rather than `res.json()`:
124
-
125
- ```js
126
- const express = require('express');
127
- app.use(
128
- '/sap/opu/odata',
129
- express.raw({ type: 'multipart/mixed' }), // populates req.body for $batch requests
130
- odataMcpGuard(),
131
- proxyToYourODataBackend
132
- );
133
- ```
134
-
135
- If a batch can't be decoded either way (missing/wrong `req.body`, no boundary, malformed MIME), it still produces one Context/Decision/audit entry (with an undefined entity) so it isn't silently unaccounted for — it's just unmasked.
136
-
137
- ## Try it
138
-
139
- A full working example lives in [`examples/bookshop`](examples/bookshop) SAP's own CAP getting-started sample, with `cap-mcp-guard` wired in and a `cap-mcp-guard.yaml` masking real fields on `CatalogService.Books`.
140
-
141
- ```bash
142
- cd examples/bookshop
143
- npm install
144
- npm test # runs enforce/observe/audit/OTel integration tests against a real CAP service
145
- npm start # boots a real server at localhost:4004 — flip cap-mcp-guard.yaml to `mode: enforce`
146
- # and hit /odata/v4/browse/Books to see masking happen live
147
- ```
148
-
149
- ## Coming soon (not in v1)
150
-
151
- - `@mcp.policy`-style CDS annotations as an alternative to `cap-mcp-guard.yaml`
152
- - Approval workflows (human-in-the-loop for sensitive operations)
153
- - Rate limiting and a dashboard UI
154
- - Actually blocking a request when `allowTools`/`maxRows` is violated (today those are computed and reported, not enforced)
155
-
156
- ## Development
157
-
158
- ```bash
159
- npm test # unit tests for lib/core, lib/policy, lib/audit, lib/otel, lib/adapters
160
- ```
161
-
162
- ## License
163
-
1
+ # cap-mcp-guard
2
+
3
+ **A CAP (CDS) plugin** auto-discovered via `cds-plugin.js` the moment it's a dependency of your project, zero manual wiring required.
4
+
5
+ CAP MCP Guard is the trust layer for AI agents accessing SAP CAP business datarequest interception, policy enforcement, field masking, and OpenTelemetry-native observability for MCP-enabled applications.
6
+
7
+ Today, organizations have only two options when exposing SAP CAP business data to AI agents: grant unrestricted access or deny access completely. CAP MCP Guard introduces a third option — controlled, observable, and policy-driven access, without requiring developers to hand-write authorization, masking, and audit logic for every entity.
8
+
9
+ We give CAP developers a standardized, reusable way to enforce AI-agent security, observability, and policy — without hand-writing that logic for every entity. Convention over implementation, in the same spirit as CAP itself.
10
+
11
+ ## Why
12
+
13
+ - **Field exposure.** When AI agents connect to CAP entities over MCP, sensitive fields (`CreditCardNo`, `Salary`, national ID numbers, ...) are visible to the agent unless someone filters them by hand, entity by entity.
14
+ - **No visibility.** Which agent accessed which entity, when, how many rows, and how long it took isn't logged anywhere standard. There's no answer when someone asks for an audit trail.
15
+ - **Excessive trust.** Even a "read-only" agent can technically reach every action/function a service exposes through the MCP layer, unless something enforces otherwise. Relying on the agent to "behave" isn't a control.
16
+ - **Enterprise distrust.** Companies want AI agents connected to business data, but security teams block it because nobody can prove what the agent will actually do. CAP MCP Guard is the third option between "wide open" and "no access at all."
17
+
18
+ ## How it works
19
+
20
+ ```
21
+ AI Request → Intercept → Evaluate Policy → Mask → Execute → Audit → Trace
22
+ ```
23
+
24
+ ```
25
+ Claude / Joule / Copilot / Custom Agent
26
+
27
+ Any MCP Runtime
28
+ (gavdilabs/cap-mcp-plugin, a custom runtime, a future official SAP
29
+ solution — it doesn't matter which)
30
+
31
+ ┌─────────────────────────┐
32
+ cap-mcp-guard
33
+
34
+ lib/core/
35
+ ├─ interceptor.js │ → attaches to CAP's srv.before/srv.after hooks
36
+ └─ context.js → builds an OTel gen_ai.*-shaped, framework-
37
+ agnostic request context
38
+
39
+ lib/policy/ │ → knows nothing about CAP. Plain JS: Context in,
40
+ ├─ config.js Decision out.
41
+ ├─ evaluator.js
42
+ ├─ masking.js
43
+ │ └─ pseudonym.js
44
+ │ │
45
+ │ lib/audit/
46
+ │ └─ log.js │ → Context + Decision → structured JSON log line
47
+ │ │
48
+ │ lib/otel/
49
+ │ └─ exporter.js │ → Context + Decision a real OTel span
50
+ │ │
51
+ │ lib/adapters/ │
52
+ └─ cap.js │ → the ONE place that knows @sap/cds.
53
+ │ │ cds-plugin.js calls this.
54
+ └─────────────────────────┘
55
+
56
+ CAP Service
57
+ ```
58
+
59
+ `lib/policy/` and `lib/core/context.js` never import `@sap/cds` — they only ever see a plain `Context` object and a plain `PolicyDefinition` object, regardless of where either one came from. That's what lets the `"cap-mcp-guard"` package.json config be swapped out for a future CDS-annotation-based source later without touching the engine itself.
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ npm install --save cap-mcp-guard
65
+ ```
66
+
67
+ This is a **CDS plugin**, not a library you wire up by hand: CAP auto-discovers `cds-plugin.js` the moment the package is a dependency of your project — no manual `require`, no server bootstrap changes. Add a `"cap-mcp-guard"` key to your project's `package.json` and it's picked up automatically the next time your CAP server starts.
68
+
69
+ ## Configure
70
+
71
+ ```json
72
+ {
73
+ "cap-mcp-guard": {
74
+ "mode": "enforce",
75
+ "entities": {
76
+ "Orders": {
77
+ "mask": ["CreditCard", "Salary"],
78
+ "maxRows": 100,
79
+ "allowTools": ["ReadOrders"]
80
+ },
81
+ "Customers": {
82
+ "mask": ["Email", "Phone"]
83
+ }
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ - Entities not listed here are fully accessible this is opt-in by design; you don't have to configure every entity up front.
90
+ - No `"cap-mcp-guard"` key at all? The guard runs in pass-through mode (a `console.warn` tells you so) rather than crashing your server.
91
+ - A config that exists but fails to parse *does* fail loudly — a broken config shouldn't fail silently.
92
+
93
+ ### Scoping the guard to specific services
94
+
95
+ If the same entities are served both to a human-facing UI and to an AI/MCP agent, masking
96
+ everything is usually wrong — the UI would see masked fields too. Add a `"services"` array
97
+ to scope the guard to only the CAP services named there; every other served service is left
98
+ completely untouched (no masking, no audit, no interceptor at all):
99
+
100
+ ```json
101
+ {
102
+ "cap-mcp-guard": {
103
+ "mode": "enforce",
104
+ "services": ["AgentCatalogService"],
105
+ "entities": {
106
+ "Customers": { "mask": ["IBAN"] }
107
+ }
108
+ }
109
+ }
110
+ ```
111
+
112
+ The recommended pattern: expose the AI/MCP-facing traffic through its own CDS service (a
113
+ projection over the same entities your UI's service already serves), point `"services"` at
114
+ that one, and leave your UI's service out of the list entirely — it keeps seeing real,
115
+ unmasked data. Omitting `"services"` keeps the default: every served service is guarded, as
116
+ before.
117
+
118
+ ### Scoping the guard to specific users
119
+
120
+ If splitting into a second CDS service isn't worth it, scope the guard by **identity**
121
+ instead, using CAP's own authenticated user (`req.user.id`, not a client-supplied header
122
+ so it can't be spoofed the way a raw HTTP header could). Add a `"users"` array: masking (and
123
+ `allowTools`/`maxRows` enforcement) only applies to requests whose authenticated user is in
124
+ that list — everyone else's request is fully passed through, exactly as if the entity had no
125
+ policy at all:
126
+
127
+ ```json
128
+ {
129
+ "cap-mcp-guard": {
130
+ "mode": "enforce",
131
+ "users": ["mcp-agent-technical-user"],
132
+ "entities": {
133
+ "Customers": { "mask": ["IBAN"] }
134
+ }
135
+ }
136
+ }
137
+ ```
138
+
139
+ Authenticate your MCP runtime as that technical user (via XSUAA/IAS, a service key bound to
140
+ the CAP app) so its requests carry that identity; your UI's human users authenticate
141
+ normally and are never in the list, so they always see real data through the same service and
142
+ the same endpoint. This is only as secure as your CAP app's auth strategy — it requires a
143
+ real, verified identity provider (XSUAA/IAS/JWT) in production. `mocked` auth (fine for local
144
+ dev, as `examples/bookshop` uses) lets `req.user.id` be set by an untrusted client-supplied
145
+ header, which defeats this entirely.
146
+
147
+ `"services"` and `"users"` compose — set both if you want a dedicated AI-facing service *and*
148
+ identity verification within it.
149
+
150
+ ### Pseudonymizing instead of redacting
151
+
152
+ Plain `mask` replaces every real value with the same fixed string, `'***MASKED***'` —
153
+ which means an AI agent can no longer tell two different customers' masked fields apart at
154
+ all (no grouping, no counting distinct values, no relational reasoning). `pseudonymize`
155
+ replaces a real value with a **fake but deterministic** one instead: the same real value
156
+ always produces the same fake value, and different real values produce different fake
157
+ values — so the agent keeps that relational structure without ever seeing the real data.
158
+
159
+ ```json
160
+ {
161
+ "cap-mcp-guard": {
162
+ "mode": "enforce",
163
+ "entities": {
164
+ "Customers": {
165
+ "mask": ["CreditCard"],
166
+ "pseudonymize": [
167
+ "Email",
168
+ { "field": "IBAN", "type": "iban" }
169
+ ]
170
+ }
171
+ }
172
+ }
173
+ }
174
+ ```
175
+
176
+ Each entry is either a bare field name (uses the generic `"opaque"` generator — a
177
+ deterministic token like `Email-7f3a9c21e1b4`, carrying no information about the real
178
+ value) or a `{ "field", "type" }` object naming a specific generator. The only built-in
179
+ typed generator today is `"iban"`: it keeps the real country code and total length, and
180
+ computes a real ISO 7064 MOD 97-10 check digit pair, so the fake IBAN passes standard IBAN
181
+ checksum validation — it isn't a real account, but nothing downstream sees it as malformed.
182
+ A field can't be listed in both `mask` and `pseudonymize` on the same entity (config fails
183
+ to load if it is). More typed generators (e.g. a Luhn-valid fake credit card number) can be
184
+ added later without changing this config shape — anything without a dedicated generator
185
+ just falls back to `"opaque"`.
186
+
187
+ **Requires a secret.** Set the `CAP_MCP_GUARD_PSEUDONYM_SECRET` environment variable (or
188
+ pass `pseudonymSecret` directly to `registerCapMcpGuard`) — every pseudonym is derived from
189
+ it via HMAC, so without it a fake value can't be reproduced or tied back to a real one.
190
+ **Never commit this value.** If any entity configures `pseudonymize` and no secret is set,
191
+ the server fails to start rather than silently producing unprotected data. Rotating the
192
+ secret invalidates every previously-issued pseudonym (the same real value will map to a new
193
+ fake one from then on) — this is expected, not a bug.
194
+
195
+ ## What you get, per request
196
+
197
+ - **Masking** — in `enforce` mode, fields listed under `mask` are replaced with `'***MASKED***'`, and fields under `pseudonymize` with a deterministic fake value (see above), in the real response. In `observe` mode nothing is touched; the guard only computes what *would* happen.
198
+ - **Audit log** — every request produces a structured JSON line (Context + Decision), to stdout and/or a file you choose.
199
+ - **OpenTelemetry spans** — every request also becomes a real span via `@opentelemetry/api`. If your app already has an OTel SDK configured (any OTLP-compatible backend — Grafana, Jaeger, Datadog, SAP Cloud Logging), the guard's spans just show up there, correctly linked into the caller's trace via W3C Trace Context (`traceparent`/`tracestate`) when present — no extra mapping needed, because the context schema was built against OTel's GenAI semantic conventions (`gen_ai.*`) from the start.
200
+
201
+ All three run independently and can each be disabled per-call (`audit: false`, `otel: false`) if you're wiring `registerCapMcpGuard` yourself instead of relying on auto-discovery.
202
+
203
+ ## Try it
204
+
205
+ A full working example lives in [`examples/bookshop`](examples/bookshop) — SAP's own CAP getting-started sample, with `cap-mcp-guard` wired in and a `"cap-mcp-guard"` package.json config masking real fields on `CatalogService.Books`.
206
+
207
+ ```bash
208
+ cd examples/bookshop
209
+ npm install
210
+ npm test # runs enforce/observe/audit/OTel integration tests against a real CAP service
211
+ npm start # boots a real server at localhost:4004 — flip package.json's "cap-mcp-guard".mode to "enforce"
212
+ # and hit /odata/v4/browse/Books to see masking happen live
213
+ ```
214
+
215
+ ## Coming soon (not in v1)
216
+
217
+ - `@mcp.policy`-style CDS annotations as an alternative to the `"cap-mcp-guard"` package.json config
218
+ - Approval workflows (human-in-the-loop for sensitive operations)
219
+ - Rate limiting and a dashboard UI
220
+ - Actually blocking a request when `allowTools`/`maxRows` is violated (today those are computed and reported, not enforced)
221
+
222
+ ## Development
223
+
224
+ ```bash
225
+ npm test # unit tests for lib/core, lib/policy, lib/audit, lib/otel, lib/adapters
226
+ ```
227
+
228
+ ## License
229
+
164
230
  MIT
@@ -1,80 +1,100 @@
1
- 'use strict';
2
-
3
- const path = require('path');
4
-
5
- const { attachInterceptor } = require('../core/interceptor');
6
- const { loadConfig } = require('../policy/config');
7
- const { logAudit } = require('../audit/log');
8
- const { exportSpan } = require('../otel/exporter');
9
-
10
- const CONFIG_FILE_NAME = 'cap-mcp-guard.yaml';
11
- const NOT_FOUND_PREFIX = `${CONFIG_FILE_NAME} not found`;
12
-
13
- /**
14
- * Loads cap-mcp-guard.yaml from the host CAP project's root. A missing
15
- * config is a valid "not configured yet" statefalls back to a
16
- * pass-through PolicyDefinition (every entity is opt-in, so this is
17
- * equivalent to no policy at all). A config that exists but fails to
18
- * parse is a user mistake and is NOT swallowed — it propagates so the
19
- * bad config gets noticed rather than silently ignored.
20
- *
21
- * @param {object} cds the @sap/cds module (used only for cds.root)
22
- */
23
- function resolvePolicyDefinition(cds) {
24
- const configPath = path.join(cds.root || process.cwd(), CONFIG_FILE_NAME);
25
-
26
- try {
27
- return loadConfig(configPath);
28
- } catch (err) {
29
- if (err.message.startsWith(NOT_FOUND_PREFIX)) {
30
- console.warn('[cap-mcp-guard] no config found, running in pass-through mode (no policies enforced)');
31
- return { mode: 'observe', entities: {} };
32
- }
33
- throw err;
34
- }
35
- }
36
-
37
- /**
38
- * The single CAP-specific connection point in this package. Everything
39
- * under lib/core and lib/policy is framework-agnostic; this file is where
40
- * @sap/cds concepts (served services, cds.ApplicationService, cds.root)
41
- * are known.
42
- *
43
- * cds-plugin.js is the only caller of this function in production; tests
44
- * call it directly against a real or fake CAP service.
45
- *
46
- * @param {object} cds the @sap/cds module (or a compatible facade)
47
- * @param {object} [options] forwarded to attachInterceptor (see interceptor.js)
48
- * @param {object} [options.policyDefinition] when omitted, loaded from
49
- * cap-mcp-guard.yaml at the project root (see resolvePolicyDefinition)
50
- * @param {object|false} [options.audit] forwarded to logAudit()'s write
51
- * options (`{ stdout, filePath }`); pass `false` to disable audit
52
- * logging entirely.
53
- * @param {object|false} [options.otel] forwarded to exportSpan()'s options
54
- * (`{ tracer }`); pass `false` to skip OTel span export entirely. With
55
- * no host-configured OTel SDK this is already a harmless no-op, so the
56
- * default is to always attempt it.
57
- * @param {(decision: object, context: object, req: object) => void} [options.onDecision]
58
- * called in addition to the built-in audit log and OTel export, not
59
- * instead of themall that apply run for every request.
60
- */
61
- function registerCapMcpGuard(cds, options = {}) {
62
- const policyDefinition = options.policyDefinition || resolvePolicyDefinition(cds);
63
- const { onDecision: userOnDecision, audit, otel } = options;
64
-
65
- const onDecision = (decision, context, req) => {
66
- if (audit !== false) logAudit(context, decision, audit);
67
- if (otel !== false) exportSpan(context, decision, otel);
68
- if (typeof userOnDecision === 'function') userOnDecision(decision, context, req);
69
- };
70
-
71
- cds.on('served', () => {
72
- for (const srv of Object.values(cds.services)) {
73
- if (srv instanceof cds.ApplicationService) {
74
- attachInterceptor(srv, { ...options, policyDefinition, onDecision });
75
- }
76
- }
77
- });
78
- }
79
-
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ const { attachInterceptor } = require('../core/interceptor');
6
+ const { loadConfig, NOT_CONFIGURED_PREFIX } = require('../policy/config');
7
+ const { logAudit } = require('../audit/log');
8
+ const { exportSpan } = require('../otel/exporter');
9
+
10
+ /**
11
+ * Loads the `"cap-mcp-guard"` key from the host CAP project's package.json.
12
+ * A missing package.json or a missing key is a valid "not configured yet"
13
+ * state — falls back to a pass-through PolicyDefinition (every entity is
14
+ * opt-in, so this is equivalent to no policy at all). A config that exists
15
+ * but fails to parse is a user mistake and is NOT swallowed it propagates
16
+ * so the bad config gets noticed rather than silently ignored.
17
+ *
18
+ * @param {object} cds the @sap/cds module (used only for cds.root)
19
+ */
20
+ function resolvePolicyDefinition(cds) {
21
+ const configPath = path.join(cds.root || process.cwd(), 'package.json');
22
+
23
+ try {
24
+ return loadConfig(configPath);
25
+ } catch (err) {
26
+ if (err.message.startsWith(NOT_CONFIGURED_PREFIX)) {
27
+ console.warn('[cap-mcp-guard] no config found, running in pass-through mode (no policies enforced)');
28
+ return { mode: 'observe', entities: {} };
29
+ }
30
+ throw err;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * The single CAP-specific connection point in this package. Everything
36
+ * under lib/core and lib/policy is framework-agnostic; this file is where
37
+ * @sap/cds concepts (served services, cds.ApplicationService, cds.root)
38
+ * are known.
39
+ *
40
+ * cds-plugin.js is the only caller of this function in production; tests
41
+ * call it directly against a real or fake CAP service.
42
+ *
43
+ * @param {object} cds the @sap/cds module (or a compatible facade)
44
+ * @param {object} [options] forwarded to attachInterceptor (see interceptor.js)
45
+ * @param {object} [options.policyDefinition] when omitted, loaded from the
46
+ * `"cap-mcp-guard"` key in the project's package.json (see resolvePolicyDefinition)
47
+ * @param {object|false} [options.audit] forwarded to logAudit()'s write
48
+ * options (`{ stdout, filePath }`); pass `false` to disable audit
49
+ * logging entirely.
50
+ * @param {object|false} [options.otel] forwarded to exportSpan()'s options
51
+ * (`{ tracer }`); pass `false` to skip OTel span export entirely. With
52
+ * no host-configured OTel SDK this is already a harmless no-op, so the
53
+ * default is to always attempt it.
54
+ * @param {(decision: object, context: object, req: object) => void} [options.onDecision]
55
+ * called in addition to the built-in audit log and OTel export, not
56
+ * instead of them all that apply run for every request.
57
+ *
58
+ * `policyDefinition.services` (see lib/policy/config.js), when set, scopes the guard to
59
+ * only the named served services any other served ApplicationService is left completely
60
+ * untouched (no interceptor attached at all, not even for observation/audit). This is how
61
+ * you keep a human-facing UI service unmasked while guarding a separate AI/MCP-facing
62
+ * service that projects the same entities. Omitted (the default) attaches to every served
63
+ * ApplicationService, as before.
64
+ *
65
+ * @param {string} [options.pseudonymSecret] used to derive pseudonyms (see
66
+ * lib/policy/pseudonym.js) for any entity configuring `pseudonymize`; when omitted,
67
+ * falls back to the `CAP_MCP_GUARD_PSEUDONYM_SECRET` env var. If any entity uses
68
+ * `pseudonymize` and neither is set, this throws immediately (at server boot, before
69
+ * any request can be served) rather than silently producing unprotected data.
70
+ */
71
+ function registerCapMcpGuard(cds, options = {}) {
72
+ const policyDefinition = options.policyDefinition || resolvePolicyDefinition(cds);
73
+ const { onDecision: userOnDecision, audit, otel } = options;
74
+
75
+ const pseudonymSecret = options.pseudonymSecret || process.env.CAP_MCP_GUARD_PSEUDONYM_SECRET;
76
+ const usesPseudonymize = Object.values(policyDefinition.entities).some(
77
+ (entityConfig) => entityConfig.pseudonymize && entityConfig.pseudonymize.length > 0
78
+ );
79
+ if (usesPseudonymize && !pseudonymSecret) {
80
+ throw new Error(
81
+ 'CAP_MCP_GUARD_PSEUDONYM_SECRET env var (or options.pseudonymSecret) is required when "pseudonymize" is configured'
82
+ );
83
+ }
84
+
85
+ const onDecision = (decision, context, req) => {
86
+ if (audit !== false) logAudit(context, decision, audit);
87
+ if (otel !== false) exportSpan(context, decision, otel);
88
+ if (typeof userOnDecision === 'function') userOnDecision(decision, context, req);
89
+ };
90
+
91
+ cds.on('served', () => {
92
+ for (const [name, srv] of Object.entries(cds.services)) {
93
+ if (!(srv instanceof cds.ApplicationService)) continue;
94
+ if (Array.isArray(policyDefinition.services) && !policyDefinition.services.includes(name)) continue;
95
+ attachInterceptor(srv, { ...options, policyDefinition, onDecision, pseudonymSecret });
96
+ }
97
+ });
98
+ }
99
+
80
100
  module.exports = { registerCapMcpGuard };